mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-11-10 12:18:25 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66ab4b24d1 | ||
|
|
5b7e34eb67 | ||
|
|
04c1f5ae76 | ||
|
|
0792a5e24f | ||
|
|
779927c995 | ||
|
|
87b77d9461 | ||
|
|
8342713d45 | ||
|
|
4175cf66c9 | ||
|
|
af54d2e346 | ||
|
|
9b1566aa6b | ||
|
|
fff2b0d097 | ||
|
|
99678969b3 | ||
|
|
0cde59a8ad | ||
|
|
36b3434b9c | ||
|
|
71be654418 | ||
|
|
47c9bfba4f | ||
|
|
7c767f43b0 | ||
|
|
15beff767d | ||
|
|
53ee436e35 | ||
|
|
e824d353f0 | ||
|
|
3f2c186771 | ||
|
|
ea7e7468df | ||
|
|
e93b771ff5 | ||
|
|
c08f2e65a8 | ||
|
|
0d90542119 | ||
|
|
2f5342b427 | ||
|
|
bc8bbf3562 | ||
|
|
cc46ce5ab6 | ||
|
|
b5ee4f7b1b | ||
|
|
9be1cb7818 | ||
|
|
fe58b4eba0 | ||
|
|
18fc758b4f | ||
|
|
5795699091 | ||
|
|
b88ca8e2e5 | ||
|
|
d504c2bf25 | ||
|
|
49662bdc91 | ||
|
|
f23dd9bff0 | ||
|
|
2a8b39bdef | ||
|
|
23e56e9581 | ||
|
|
3d09072ea7 | ||
|
|
4daa54c12e | ||
|
|
5457ca0965 | ||
|
|
1dd0b8fbe8 | ||
|
|
5db7516f40 | ||
|
|
bd34c94222 | ||
|
|
9754c4c639 | ||
|
|
db292e947d | ||
|
|
ec0f30106d | ||
|
|
1c76fb76ea | ||
|
|
f915ca2462 | ||
|
|
55481adede | ||
|
|
7e240e4136 | ||
|
|
3fb2f7261f | ||
|
|
bea51343ef | ||
|
|
42fbab8058 | ||
|
|
dca46af3fc | ||
|
|
4aab1f50a4 | ||
|
|
44ada6ad5a | ||
|
|
e3631e88c6 | ||
|
|
fdf51a654c | ||
|
|
0dc9933409 | ||
|
|
044d093ec5 | ||
|
|
d7a79a547f | ||
|
|
d131419907 | ||
|
|
c68456b205 | ||
|
|
5aed3a5539 | ||
|
|
c214a49afb | ||
|
|
b28e0d5442 | ||
|
|
2a27e542e9 | ||
|
|
66fd49ee46 | ||
|
|
d0739b7210 | ||
|
|
435ddeee34 | ||
|
|
c19ce19f90 | ||
|
|
c23a1cb343 | ||
|
|
d2e8e5650a | ||
|
|
50d413f464 | ||
|
|
5e76f904ee | ||
|
|
6838868fdc | ||
|
|
26e27e3503 | ||
|
|
9780e22792 | ||
|
|
076c522ccc | ||
|
|
518dd04679 | ||
|
|
d9c4d33a7c | ||
|
|
b6e9038f0c | ||
|
|
a57ffb0ea9 | ||
|
|
b7d1e99e8c | ||
|
|
3a455595e7 | ||
|
|
c50c06530b | ||
|
|
1aca775cb9 | ||
|
|
556738fd4d | ||
|
|
f1bd157438 | ||
|
|
6acf346262 | ||
|
|
82529d6402 | ||
|
|
8c5b4d8ffd | ||
|
|
d780474583 | ||
|
|
9972e78dc8 | ||
|
|
41713715ca | ||
|
|
06b02f7731 | ||
|
|
56ab784117 | ||
|
|
717877db3f | ||
|
|
1454252acd | ||
|
|
6b26999e34 | ||
|
|
1028e139e4 | ||
|
|
7d7ed25715 | ||
|
|
3270873cc8 | ||
|
|
81e7044a17 | ||
|
|
3f1c0f27d3 | ||
|
|
2afd723cd8 | ||
|
|
f5e6dfecfb | ||
|
|
778ab6b375 | ||
|
|
092d212ad7 | ||
|
|
e7e69abb84 | ||
|
|
f64d43b9ae | ||
|
|
77dfc3d3c1 | ||
|
|
2dfd691169 | ||
|
|
c699dc96d4 | ||
|
|
5dcaf28d7c | ||
|
|
de65105744 | ||
|
|
a54b73f153 | ||
|
|
ccbffa76ff | ||
|
|
4725153962 | ||
|
|
a29eae9dc2 | ||
|
|
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 | ||
|
|
76f09db610 | ||
|
|
d51602b150 | ||
|
|
af2de98c41 | ||
|
|
6311329772 | ||
|
|
a08f821c54 | ||
|
|
fc4a409292 | ||
|
|
62ed330053 | ||
|
|
0d832092a5 | ||
|
|
6629abed32 | ||
|
|
46bf3274d8 | ||
|
|
18fc3fe770 | ||
|
|
e72dfc3bfd |
No files matched your search
@@ -0,0 +1,7 @@
|
||||
# Force LF everywhere: these files run inside Linux containers
|
||||
* text=auto eol=lf
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
@@ -1,6 +1,16 @@
|
||||
<!-- WARNING: Any PR without a description will be closed. The title is not enough! -->
|
||||
<!--
|
||||
|
||||
<!-- ANOTHER WARNING: Don't go creating a duplicate PR! Check that someone hasn't already created something that tackles your fix and if so, help them first -->
|
||||
WARNINGS:
|
||||
|
||||
1. An AI bot will comment on this PR! If you don't get a perfect score or don't
|
||||
address any issues raised, your PR won't get merged!
|
||||
|
||||
2. Any PR without a description will be closed. The title is not enough!
|
||||
|
||||
3. Don't go creating a duplicate PR! Check that someone hasn't already created
|
||||
something that tackles your fix and if so, help them first
|
||||
|
||||
-->
|
||||
|
||||
## Why
|
||||
|
||||
@@ -28,4 +38,3 @@ you change will break API for existing users -->
|
||||
|
||||
- [ ] AI was used to write this
|
||||
- [ ] AI was used to review this
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -9,4 +9,5 @@ test/node_modules
|
||||
docker/dev/dnsrouter-config.json.tmp
|
||||
docker/dev/resolv.conf
|
||||
.claude
|
||||
test/__pycache__
|
||||
|
||||
@@ -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.0-green.svg?style=for-the-badge">
|
||||
<img src="https://img.shields.io/badge/version-2.17.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.6.6/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,28 +32,28 @@
|
||||
"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",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-baidu",
|
||||
"name": "baidu",
|
||||
"package_name": "certbot-dns-baidu",
|
||||
"version": "~=0.1.1"
|
||||
"package_name": "certbot-dns-baidu-bcd",
|
||||
"version": "~=2.0.0"
|
||||
},
|
||||
"beget": {
|
||||
"credentials": "# Beget API credentials used by Certbot\nbeget_plugin_username = username\nbeget_plugin_password = password",
|
||||
"credentials": "# Beget API credentials used by Certbot\n# Find on Settings -> Security -> BegetAPI\ndns_beget_plugin_username = username\ndns_beget_plugin_password = password",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "beget-plugin",
|
||||
"full_plugin_name": "dns-beget-plugin",
|
||||
"name": "Beget",
|
||||
"package_name": "certbot-beget-plugin",
|
||||
"version": "~=1.0.0.dev9"
|
||||
"package_name": "certbot-dns-beget-plugin",
|
||||
"version": "~=1.0.0"
|
||||
},
|
||||
"bunny": {
|
||||
"credentials": "# Bunny API token used by Certbot (see https://dash.bunny.net/account/settings)\ndns_bunny_api_key = xxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxx",
|
||||
@@ -173,7 +173,7 @@
|
||||
"full_plugin_name": "dns-multi",
|
||||
"name": "DnsMulti",
|
||||
"package_name": "certbot-dns-multi",
|
||||
"version": "~=4.9"
|
||||
"version": "~=5.6.0"
|
||||
},
|
||||
"dnspod": {
|
||||
"credentials": "dns_dnspod_email = \"email@example.com\"\ndns_dnspod_api_token = \"id,key\"",
|
||||
@@ -224,12 +224,20 @@
|
||||
"version": "~=0.1.2"
|
||||
},
|
||||
"edgedns": {
|
||||
"credentials": "edgedns_client_secret = as3d1asd5d1a32sdfsdfs2d1asd5=\nedgedns_host = sdflskjdf-dfsdfsdf-sdfsdfsdf.luna.akamaiapis.net\nedgedns_access_token = kjdsi3-34rfsdfsdf-234234fsdfsdf\nedgedns_client_token = dkfjdf-342fsdfsd-23fsdfsdfsdf",
|
||||
"credentials": "client_token = YOUR_CLIENT_TOKEN\naccess_token = YOUR_ACCESS_TOKEN\nclient_secret = YOUR_CLIENT_SECRET\nhost = YOUR_HOST.luna.akamaiapis.net\n# Optional: account_key = YOUR_ACCOUNT_KEY",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "edgedns",
|
||||
"name": "Akamai Edge DNS",
|
||||
"package_name": "certbot-plugin-edgedns",
|
||||
"version": "==0.3.0"
|
||||
},
|
||||
"edgeone": {
|
||||
"credentials": "dns_edgeone_secret_id = YOUR_TENCENTCLOUD_SECRET_ID\ndns_edgeone_secret_key = YOUR_TENCENTCLOUD_SECRET_KEY",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "edgedns",
|
||||
"name": "Akamai Edge DNS",
|
||||
"package_name": "certbot-plugin-edgedns",
|
||||
"version": "~=0.1.0"
|
||||
"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/",
|
||||
@@ -325,7 +333,7 @@
|
||||
"full_plugin_name": "dns-hetzner",
|
||||
"name": "Hetzner",
|
||||
"package_name": "certbot-dns-hetzner",
|
||||
"version": "~=1.0.4"
|
||||
"version": "~=4.0.0"
|
||||
},
|
||||
"hetzner-cloud": {
|
||||
"credentials": "dns_hetzner_cloud_api_token = your_api_token_here",
|
||||
@@ -373,7 +381,7 @@
|
||||
"full_plugin_name": "dns-infomaniak",
|
||||
"name": "Infomaniak",
|
||||
"package_name": "certbot-dns-infomaniak",
|
||||
"version": "~=0.2.2"
|
||||
"version": "~=0.3.3"
|
||||
},
|
||||
"inwx": {
|
||||
"credentials": "dns_inwx_url = https://api.domrobot.com/xmlrpc/\ndns_inwx_username = your_username\ndns_inwx_password = your_password\ndns_inwx_shared_secret = your_shared_secret optional",
|
||||
@@ -391,6 +399,14 @@
|
||||
"package_name": "certbot-dns-ionos",
|
||||
"version": "==2022.11.24"
|
||||
},
|
||||
"ip-projects": {
|
||||
"credentials": "dns_ipp_acme_api_key = <API key>",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-ipp",
|
||||
"name": "ip-projects.de",
|
||||
"package_name": "certbot-dns-ipp",
|
||||
"version": "~=1.0.0"
|
||||
},
|
||||
"ispconfig": {
|
||||
"credentials": "dns_ispconfig_username = myremoteuser\ndns_ispconfig_password = verysecureremoteuserpassword\ndns_ispconfig_endpoint = https://localhost:8080",
|
||||
"dependencies": "",
|
||||
@@ -591,6 +607,14 @@
|
||||
"package_name": "certbot-dns-route53",
|
||||
"version": "=={{certbot-version}}"
|
||||
},
|
||||
"scaleway": {
|
||||
"credentials": "dns_scaleway_application_token = b3a0b9a9-3814-4f12-95b0-a7473bf8b306",
|
||||
"dependencies": "",
|
||||
"full_plugin_name": "dns-scaleway",
|
||||
"name": "Scaleway",
|
||||
"package_name": "certbot-dns-scaleway",
|
||||
"version": "~=0.0.7"
|
||||
},
|
||||
"selectelv2": {
|
||||
"credentials": "dns_selectel_api_v2_account_id = your_account_id\ndns_selectel_api_v2_project_name = your_project\ndns_selectel_api_v2_username = your_username\ndns_selectel_api_v2_password = your_password",
|
||||
"dependencies": "",
|
||||
@@ -629,7 +653,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 +661,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 +685,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 +710,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;
|
||||
+3
-1
@@ -26,7 +26,9 @@ async function appStart() {
|
||||
})
|
||||
.then(() => {
|
||||
internalCertificate.initTimer();
|
||||
internalIpRanges.initTimer();
|
||||
if (IP_RANGES_FETCH_ENABLED) {
|
||||
internalIpRanges.initTimer();
|
||||
}
|
||||
|
||||
const server = app.listen(3000, () => {
|
||||
logger.info(`Backend PID ${process.pid} listening on port 3000 ...`);
|
||||
|
||||
+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");
|
||||
|
||||
+108
-43
@@ -2,7 +2,10 @@ import fs from "node:fs";
|
||||
import batchflow from "batchflow";
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { isValid, unsafeValueError } from "../lib/nginx-values.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 +18,36 @@ 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 +99,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 +213,34 @@ 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 +256,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 +317,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 +388,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 +430,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 +492,29 @@ 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) {
|
||||
// One "user:hash" line each; a ":" or line break in the name would corrupt the file
|
||||
if (!isValid("accessUsername", item.username)) {
|
||||
reject(unsafeValueError(`Access List #${list.id}: user name is not allowed`));
|
||||
return;
|
||||
}
|
||||
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 +536,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(", ")}]`);
|
||||
|
||||
+381
-23
@@ -24,8 +24,163 @@ const certbotCommand = "certbot";
|
||||
const certbotLogsDir = "/data/logs";
|
||||
const certbotWorkDir = "/tmp/letsencrypt-lib";
|
||||
|
||||
// Certbot output lines that say nothing about why a request failed
|
||||
const certbotNoise = [
|
||||
/^Saving debug log/,
|
||||
/^Ask for help/,
|
||||
/^See the logfile/,
|
||||
/^(- )+-?$/,
|
||||
/^Processing \//,
|
||||
/^Renewing an existing/,
|
||||
/^Requesting a certificate/,
|
||||
/^Plugins selected/,
|
||||
/^Account registered/,
|
||||
/^Waiting \d+ seconds/,
|
||||
/^All renewals failed/,
|
||||
/^\S+\/fullchain\.pem \(failure\)$/,
|
||||
/^\d+ renew failure\(s\)/,
|
||||
];
|
||||
|
||||
// Plain-language hints for failures that are common and fixable outside NPM.
|
||||
// [pattern, hint, dnsProvider?]: a hint with a dnsProvider only applies to certificates using that
|
||||
// provider, so a generic message (e.g. "Authentication error" from another DNS plugin) isn't
|
||||
// reported as a Cloudflare problem.
|
||||
const certbotHints = [
|
||||
// Checked first: a rate limit is unambiguous and the most useful thing to report
|
||||
[
|
||||
/too many (certificates|failed authorizations|new orders)|rateLimited/i,
|
||||
() => "Let's Encrypt rate limit reached. Wait before retrying (see https://letsencrypt.org/docs/rate-limits/).",
|
||||
],
|
||||
[
|
||||
/expected \S+ to be a symlink/i,
|
||||
() =>
|
||||
"This certificate's files in /etc/letsencrypt were copied without their symlinks (live/ must link to archive/). Restore /etc/letsencrypt from a copy made with tar or rsync -a, or delete this certificate and create it again.",
|
||||
],
|
||||
[
|
||||
/Renewal configuration file \S+ is broken/i,
|
||||
() =>
|
||||
"This certificate's files in /etc/letsencrypt are damaged or missing. Restore /etc/letsencrypt from a backup, or delete this certificate and create it again.",
|
||||
],
|
||||
[
|
||||
/\b9109\b.*?location: ([0-9a-f.:]+)/i,
|
||||
(m) => {
|
||||
const ip = m[1].replace(/\.+$/, "");
|
||||
return `Cloudflare refused the API token from this server's public IP (${ip}). The token has Client IP Address Filtering: add ${ip} to the token in Cloudflare (My Profile > API Tokens), or remove the filter, then renew again.`;
|
||||
},
|
||||
"cloudflare",
|
||||
],
|
||||
[
|
||||
// certbot-dns-cloudflare errors read "<context>: <code> <message>", e.g.
|
||||
// "Error determining zone_id: 9109 Invalid access token."
|
||||
/: (6003|6111|9103|9109|10000) [A-Za-z]|Invalid request headers|Invalid access token|Authentication error/i,
|
||||
() =>
|
||||
"Cloudflare rejected the API token. Check the token value and that it has Zone > DNS > Edit for this domain's zone.",
|
||||
"cloudflare",
|
||||
],
|
||||
[
|
||||
/NXDOMAIN|DNS problem/i,
|
||||
() => "Let's Encrypt could not look up the domain. Check the DNS records of every domain on the certificate.",
|
||||
],
|
||||
[
|
||||
/Timeout during connect|Connection refused|Invalid response from/i,
|
||||
() =>
|
||||
"Let's Encrypt could not reach this server over HTTP (port 80) for the challenge. Check that the domain points here and port 80 is open, or use a DNS challenge.",
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* The values in a DNS credentials file ("key = value" lines), for redacting from certbot output.
|
||||
*
|
||||
* @param {String} [credentials]
|
||||
* @returns {String[]}
|
||||
*/
|
||||
const credentialValues = (credentials) =>
|
||||
`${credentials || ""}`
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/^[^=:]*[=:]/, "").trim())
|
||||
.filter((value) => value.length >= 4);
|
||||
|
||||
/**
|
||||
* Removes anything that could be a secret from certbot output before it leaves the server.
|
||||
* DNS plugins can echo provider errors verbatim, including request URLs that carry API keys in
|
||||
* the query string (e.g. "401 Client Error ... for url: https://...?token=..."), so this strips
|
||||
* the given credential values, URL query strings and userinfo, and key=value pairs whose name
|
||||
* looks secret.
|
||||
*
|
||||
* @param {String} text
|
||||
* @param {String[]} [secrets]
|
||||
* @returns {String}
|
||||
*/
|
||||
const redactCertbotOutput = (text, secrets = []) => {
|
||||
let out = `${text || ""}`;
|
||||
for (const secret of [...secrets].sort((a, b) => b.length - a.length)) {
|
||||
out = out.split(secret).join("[REDACTED]");
|
||||
}
|
||||
return out
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, "$1 [REDACTED]")
|
||||
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/\s@]*@/gi, "$1[REDACTED]@")
|
||||
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s?#]*)\?[^\s#]*/gi, "$1?[REDACTED]")
|
||||
.replace(
|
||||
/\b([\w-]*(?:token|key|secret|password|passwd|pass|auth|credential|signature|sig)[\w-]*)(["']?\s*[=:]\s*["']?)[^\s"'&,;]+/gi,
|
||||
"$1$2[REDACTED]",
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Turns certbot's stderr into a message the UI can show: a hint for common causes plus certbot's own reason.
|
||||
* The output is redacted first (see redactCertbotOutput), since DNS plugins can echo credentials.
|
||||
*
|
||||
* @param {String} stderr
|
||||
* @param {String} [dnsProvider] The certificate's DNS provider, if it uses a DNS challenge
|
||||
* @param {String[]} [secrets] Values that must never appear in the message
|
||||
* @returns {String}
|
||||
*/
|
||||
const certbotFailureMessage = (stderr, dnsProvider, secrets) => {
|
||||
const reason = redactCertbotOutput(stderr, secrets)
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !certbotNoise.some((re) => re.test(line)))
|
||||
.join(" ")
|
||||
.slice(0, 800);
|
||||
|
||||
for (const [pattern, hint, provider] of certbotHints) {
|
||||
if (provider && provider !== dnsProvider) {
|
||||
continue;
|
||||
}
|
||||
const match = reason.match(pattern);
|
||||
if (match) {
|
||||
return `${hint(match)} (Certbot: ${reason})`;
|
||||
}
|
||||
}
|
||||
return reason ? `Certbot failed: ${reason}` : "Certbot failed. See /data/logs/letsencrypt.log for details.";
|
||||
};
|
||||
|
||||
/**
|
||||
* Runs certbot; a failure becomes a public error with the real reason instead of "Internal Error".
|
||||
*
|
||||
* @param {Array} args
|
||||
* @param {Object} [opts]
|
||||
* @param {String} [dnsProvider] Passed through so provider-specific hints only show for that provider
|
||||
* @param {String} [credentials] The DNS credentials file contents, so its values are redacted from errors
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const runCertbot = async (args, opts, dnsProvider, credentials) => {
|
||||
try {
|
||||
return await utils.execFile(certbotCommand, args, opts);
|
||||
} catch (err) {
|
||||
if (err instanceof error.CommandError) {
|
||||
const secrets = credentialValues(credentials);
|
||||
logger.error(redactCertbotOutput(err.message, secrets));
|
||||
// The raw error is not attached as `previous`: the API error handler returns
|
||||
// `previous` to the client for certificate routes, and it holds unredacted output.
|
||||
throw new error.ValidationError(certbotFailureMessage(err.message, dnsProvider, secrets));
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const omissions = () => {
|
||||
return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials"];
|
||||
return ["is_deleted", "owner.is_deleted", "meta.dns_provider_credentials", "meta.certificate_key"];
|
||||
};
|
||||
|
||||
const internalCertificate = {
|
||||
@@ -75,6 +230,7 @@ const internalCertificate = {
|
||||
* instance of Certbot is already running.'
|
||||
*/
|
||||
let sequence = Promise.resolve();
|
||||
let renewed = 0;
|
||||
|
||||
certificates.forEach((certificate) => {
|
||||
sequence = sequence.then(() =>
|
||||
@@ -88,7 +244,11 @@ const internalCertificate = {
|
||||
token: tokenModel(),
|
||||
},
|
||||
{ id: certificate.id },
|
||||
{ reload: false }, // one reload for the whole batch, below
|
||||
)
|
||||
.then(() => {
|
||||
renewed++;
|
||||
})
|
||||
.catch((err) => {
|
||||
// Don't want to stop the train here, just log the error
|
||||
logger.error(err.message);
|
||||
@@ -96,7 +256,11 @@ const internalCertificate = {
|
||||
);
|
||||
});
|
||||
|
||||
return sequence;
|
||||
return sequence.then(() => {
|
||||
if (renewed) {
|
||||
return internalCertificate.reloadAfterRenew();
|
||||
}
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
logger.info("Completed SSL cert renew process");
|
||||
@@ -395,12 +559,35 @@ const internalCertificate = {
|
||||
*/
|
||||
delete: async (access, data) => {
|
||||
await access.can("certificates:delete", data.id);
|
||||
const row = await internalCertificate.get(access, { id: data.id });
|
||||
const hostRelations = ["proxy_hosts", "redirection_hosts", "dead_hosts", "streams"];
|
||||
const row = await internalCertificate.get(access, { id: data.id, expand: hostRelations });
|
||||
|
||||
if (!row?.id) {
|
||||
throw new error.ItemNotFoundError(data.id);
|
||||
}
|
||||
|
||||
// A certificate that hosts still use can't be deleted: their rendered configs include its
|
||||
// files, and revoking it would leave them serving a revoked certificate. Hosts may belong
|
||||
// to other users, so only counts are reported.
|
||||
const labels = {
|
||||
proxy_hosts: "proxy host",
|
||||
redirection_hosts: "redirection host",
|
||||
dead_hosts: "404 host",
|
||||
streams: "stream",
|
||||
};
|
||||
const usage = hostRelations
|
||||
.map((rel) => ({ rel, count: row[rel]?.length || 0 }))
|
||||
.filter((u) => u.count > 0)
|
||||
.map((u) => `${u.count} ${labels[u.rel]}${u.count === 1 ? "" : "s"}`);
|
||||
if (usage.length) {
|
||||
throw new error.ValidationError(
|
||||
`Certificate is in use by ${usage.join(", ")}. Remove it from them before deleting it.`,
|
||||
);
|
||||
}
|
||||
for (const rel of hostRelations) {
|
||||
delete row[rel];
|
||||
}
|
||||
|
||||
await certificateModel.query().where("id", row.id).patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
@@ -416,12 +603,47 @@ const internalCertificate = {
|
||||
});
|
||||
|
||||
if (row.provider === "letsencrypt") {
|
||||
// Revoke the cert
|
||||
// Revoke the cert. Errors are logged by revokeLetsEncryptSsl.
|
||||
await internalCertificate.revokeLetsEncryptSsl(row);
|
||||
// Certbot leaves the files behind when it can't revoke (e.g. a broken renewal config),
|
||||
// so remove them either way. No host uses them; that was checked above.
|
||||
// The row is already deleted and audited, so leftover files are logged, not thrown:
|
||||
// an error here would report a completed delete as failed, and a retry would 404.
|
||||
const leftover = internalCertificate.removeLetsEncryptFiles(row.id);
|
||||
if (leftover.length) {
|
||||
logger.warn(
|
||||
`Certificate #${row.id} was deleted, but these files could not be removed and should be deleted by hand: ${leftover.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Removes a certificate's certbot files, as `certbot delete` would. Never throws.
|
||||
*
|
||||
* @param {Number} certificateId
|
||||
* @returns {String[]} paths that could not be removed
|
||||
*/
|
||||
removeLetsEncryptFiles: (certificateId) => {
|
||||
const paths = [
|
||||
internalCertificate.getLiveCertPath(certificateId),
|
||||
`/etc/letsencrypt/archive/npm-${certificateId}`,
|
||||
`/etc/letsencrypt/renewal/npm-${certificateId}.conf`,
|
||||
`/etc/letsencrypt/credentials/credentials-${certificateId}`,
|
||||
];
|
||||
const failed = [];
|
||||
for (const p of paths) {
|
||||
try {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
logger.error(`Could not remove ${p}: ${err.message}`);
|
||||
failed.push(p);
|
||||
}
|
||||
}
|
||||
return failed;
|
||||
},
|
||||
|
||||
/**
|
||||
* All Certs
|
||||
*
|
||||
@@ -545,13 +767,15 @@ const internalCertificate = {
|
||||
|
||||
/**
|
||||
* Validates that the certs provided are good.
|
||||
* No access required here, nothing is changed or stored.
|
||||
* Nothing is changed or stored, but the files go through openssl, so it needs the same access as creating one.
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
* @param {Object} data.files
|
||||
* @returns {Promise}
|
||||
*/
|
||||
validate: (data) => {
|
||||
validate: async (access, data) => {
|
||||
await access.can("certificates:create");
|
||||
// Put file contents into an object
|
||||
const files = {};
|
||||
_.map(data.files, (file, name) => {
|
||||
@@ -600,27 +824,75 @@ const internalCertificate = {
|
||||
throw new error.ValidationError("Cannot upload certificates for this type of provider");
|
||||
}
|
||||
|
||||
const validations = await internalCertificate.validate(data);
|
||||
const validations = await internalCertificate.validate(access, data);
|
||||
if (typeof validations.certificate === "undefined") {
|
||||
throw new error.ValidationError("Certificate file was not provided");
|
||||
}
|
||||
|
||||
// row came through get(), which omits meta.certificate_key so it never goes out over
|
||||
// the API. update() replaces meta wholesale, so build on the stored meta (read from the
|
||||
// model directly) or an upload without a key would erase the stored one.
|
||||
const stored = await certificateModel.query().where("id", row.id).first();
|
||||
const meta = { ...(stored?.meta || {}) };
|
||||
const uploaded = [];
|
||||
|
||||
_.map(data.files, (file, name) => {
|
||||
if (internalCertificate.allowedSslFiles.indexOf(name) !== -1) {
|
||||
row.meta[name] = file.data.toString();
|
||||
meta[name] = file.data.toString();
|
||||
uploaded.push(name);
|
||||
}
|
||||
});
|
||||
|
||||
// Fail before anything is written: without a key, writeCustomCert would overwrite
|
||||
// fullchain.pem and then fail on privkey.pem, leaving nginx with a mismatched pair.
|
||||
if (!meta.certificate_key) {
|
||||
throw new error.ValidationError("Certificate Key file was not provided");
|
||||
}
|
||||
// The key may be the stored one (a certificate-only upload), so check the pair belongs
|
||||
// together; a mismatch would make nginx -t fail for every host on the next reload.
|
||||
await internalCertificate.checkKeyMatchesCertificate(meta.certificate, meta.certificate_key);
|
||||
|
||||
const certificate = await internalCertificate.update(access, {
|
||||
id: data.id,
|
||||
expires_on: moment(validations.certificate.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
|
||||
domain_names: validations.certificate.cn ? [validations.certificate.cn] : [],
|
||||
meta: _.clone(row.meta), // Prevent the update method from changing this value that we'll use later
|
||||
meta: _.clone(meta), // Prevent the update method from changing this value that we'll use later
|
||||
});
|
||||
|
||||
certificate.meta = row.meta;
|
||||
certificate.meta = meta;
|
||||
await internalCertificate.writeCustomCert(certificate);
|
||||
return _.pick(row.meta, internalCertificate.allowedSslFiles);
|
||||
// Only echo back what was uploaded in this request, never the stored key
|
||||
return _.pick(meta, uploaded);
|
||||
},
|
||||
|
||||
/**
|
||||
* Throws unless the private key is the one the certificate was issued for, by comparing the
|
||||
* public key derived from each.
|
||||
*
|
||||
* @param {String} certificate PEM certificate (the leaf comes first)
|
||||
* @param {String} privateKey PEM private key
|
||||
* @returns {Promise}
|
||||
*/
|
||||
checkKeyMatchesCertificate: async (certificate, privateKey) => {
|
||||
const certPath = await tempWrite(certificate);
|
||||
const keyPath = await tempWrite(privateKey);
|
||||
try {
|
||||
const [certPub, keyPub] = await Promise.all([
|
||||
utils.execFile("openssl", ["x509", "-in", certPath, "-pubkey", "-noout"]),
|
||||
utils.execFile("openssl", ["pkey", "-in", keyPath, "-pubout"]),
|
||||
]);
|
||||
if (!certPub.trim() || certPub.trim() !== keyPub.trim()) {
|
||||
throw new error.ValidationError("Certificate does not match the Certificate Key");
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof error.ValidationError) {
|
||||
throw err;
|
||||
}
|
||||
throw new error.ValidationError("Could not check that the Certificate matches the Certificate Key");
|
||||
} finally {
|
||||
fs.rmSync(certPath, { force: true });
|
||||
fs.rmSync(keyPath, { force: true });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -811,7 +1083,7 @@ const internalCertificate = {
|
||||
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
logger.success(result);
|
||||
return result;
|
||||
},
|
||||
@@ -860,7 +1132,8 @@ const internalCertificate = {
|
||||
if (hasConfigArg) {
|
||||
args.push(`--${dnsPlugin.full_plugin_name}-credentials`, credentialsLocation);
|
||||
}
|
||||
if (certificate.meta.propagation_seconds !== undefined) {
|
||||
// The route53 plugin waits for the change through the AWS API and has no propagation option
|
||||
if (certificate.meta.propagation_seconds !== undefined && certificate.meta.dns_provider !== "route53") {
|
||||
args.push(
|
||||
`--${dnsPlugin.full_plugin_name}-propagation-seconds`,
|
||||
certificate.meta.propagation_seconds.toString(),
|
||||
@@ -878,13 +1151,28 @@ const internalCertificate = {
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(
|
||||
args,
|
||||
adds.opts,
|
||||
certificate.meta.dns_provider,
|
||||
certificate.meta.dns_provider_credentials,
|
||||
);
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -894,7 +1182,8 @@ const internalCertificate = {
|
||||
* @param {Number} data.id
|
||||
* @returns {Promise}
|
||||
*/
|
||||
renew: async (access, data) => {
|
||||
renew: async (access, data, options = {}) => {
|
||||
const reload = options.reload !== false;
|
||||
await access.can("certificates:update", data);
|
||||
const certificate = await internalCertificate.get(access, data);
|
||||
|
||||
@@ -920,12 +1209,34 @@ const internalCertificate = {
|
||||
meta: updatedCertificate,
|
||||
});
|
||||
|
||||
// The renewal has succeeded and been recorded at this point. A reload failure
|
||||
// (e.g. an unrelated host's config is broken) must not turn it into an error;
|
||||
// nginx picks the new files up on the next successful reload.
|
||||
if (reload) {
|
||||
await internalCertificate.reloadAfterRenew();
|
||||
}
|
||||
|
||||
return updatedCertificate;
|
||||
}
|
||||
|
||||
throw new error.ValidationError("Only Let'sEncrypt certificates can be renewed");
|
||||
},
|
||||
|
||||
/**
|
||||
* Reloads nginx so it serves renewed certificate files. Errors are logged, not thrown.
|
||||
*
|
||||
* @returns {Promise<Boolean>} whether the reload succeeded
|
||||
*/
|
||||
reloadAfterRenew: async () => {
|
||||
try {
|
||||
await internalNginx.reload();
|
||||
return true;
|
||||
} catch (err) {
|
||||
logger.error(`Certificate renewed but nginx could not be reloaded: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} certificate the certificate row
|
||||
* @returns {Promise}
|
||||
@@ -962,7 +1273,7 @@ const internalCertificate = {
|
||||
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
logger.info(result);
|
||||
return result;
|
||||
},
|
||||
@@ -981,6 +1292,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 +1356,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 runCertbot(args, adds.opts, certificate.meta.dns_provider, credentials);
|
||||
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, () => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -1042,7 +1400,7 @@ const internalCertificate = {
|
||||
logger.info(`Command: ${certbotCommand} ${args ? args.join(" ") : ""}`);
|
||||
|
||||
try {
|
||||
const result = await utils.execFile(certbotCommand, args, adds.opts);
|
||||
const result = await runCertbot(args, adds.opts);
|
||||
await utils.exec(`rm -f '/etc/letsencrypt/credentials/credentials-${certificate.id}' || true`);
|
||||
logger.info(result);
|
||||
return result;
|
||||
|
||||
@@ -54,9 +54,9 @@ const internalDeadHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
const row = await deadHostModel.query()
|
||||
.insertAndFetch(thisData)
|
||||
.then(utils.omitRow(omissions()));
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
|
||||
// Add to audit log
|
||||
await internalAuditLog.add(access, {
|
||||
@@ -132,6 +132,9 @@ const internalDeadHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, data, row);
|
||||
|
||||
if (createCertificate) {
|
||||
const cert = await internalCertificate.createQuickCertificate(access, {
|
||||
domain_names: data.domain_names || row.domain_names,
|
||||
@@ -153,12 +156,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 +167,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 +226,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 +258,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 +272,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 +297,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 +308,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 +335,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)
|
||||
|
||||
@@ -1,10 +1,110 @@
|
||||
import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import deadHostModel from "../models/dead_host.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import redirectionHostModel from "../models/redirection_host.js";
|
||||
|
||||
const internalHost = {
|
||||
/**
|
||||
* Only admins may set or change advanced_config (raw nginx config). Throws for anyone else.
|
||||
* On create (no `row`), any non-empty advanced_config is refused, including on locations.
|
||||
* On update, the host's advanced_config must be unchanged (or omitted), and locations must
|
||||
* only carry advanced configs the host already has (see assertLocationAdvancedConfigUnchanged).
|
||||
*
|
||||
* @param {Access} access
|
||||
* @param {Object} data incoming host data; data.locations may be filled in on update
|
||||
* @param {Object} [row] the existing host, when updating
|
||||
* @throws {PermissionError}
|
||||
*/
|
||||
assertAdvancedConfigAllowed: (access, data, row) => {
|
||||
if (access.isAdmin?.()) {
|
||||
return;
|
||||
}
|
||||
const deny = () => {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
};
|
||||
|
||||
if (!row) {
|
||||
const hasConfig = (v) => typeof v === "string" && v.trim() !== "";
|
||||
if (hasConfig(data.advanced_config)) {
|
||||
deny();
|
||||
}
|
||||
if (Array.isArray(data.locations) && data.locations.some((loc) => hasConfig(loc?.advanced_config))) {
|
||||
deny();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof data.advanced_config !== "undefined" && data.advanced_config !== row.advanced_config) {
|
||||
deny();
|
||||
}
|
||||
if (Array.isArray(data.locations)) {
|
||||
internalHost.assertLocationAdvancedConfigUnchanged(data.locations, row.locations);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* For non-admins: every location's advanced_config must be empty or one that already exists
|
||||
* on this host. Matching is by value, not path, so renaming or reordering a location that
|
||||
* carries admin-written config is allowed, but new or edited config is not. Each existing
|
||||
* config can be used once (no duplicating it onto other paths), and same-path matches are
|
||||
* taken first. Clearing a config is allowed: deleting the location is, and has the same
|
||||
* effect, and a location deleted and re-added at the same path arrives with an empty config.
|
||||
* A location that omits advanced_config keeps the one at its path, unless another location
|
||||
* explicitly carries it. Mutates `locations`.
|
||||
*
|
||||
* @param {Array} locations incoming locations
|
||||
* @param {Array} [existingLocations] the host's current locations
|
||||
* @throws {PermissionError}
|
||||
*/
|
||||
assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => {
|
||||
const existing = Array.isArray(existingLocations) ? existingLocations : [];
|
||||
const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || "";
|
||||
const pool = existing.map((l) => l.advanced_config || "").filter(Boolean);
|
||||
const take = (value) => {
|
||||
const i = pool.indexOf(value);
|
||||
if (i === -1) {
|
||||
return false;
|
||||
}
|
||||
pool.splice(i, 1);
|
||||
return true;
|
||||
};
|
||||
const deny = () => {
|
||||
throw new errs.PermissionError("You do not have permission to modify advanced configuration");
|
||||
};
|
||||
|
||||
// Pass 1: locations that keep the config at their own path
|
||||
const unresolved = [];
|
||||
const omitted = [];
|
||||
for (const loc of locations) {
|
||||
const atPath = advAtPath(loc.path);
|
||||
if (typeof loc.advanced_config === "undefined") {
|
||||
omitted.push(loc);
|
||||
} else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) {
|
||||
// unchanged
|
||||
} else {
|
||||
unresolved.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 2: renamed/moved locations may carry an existing config not already claimed, or
|
||||
// none at all
|
||||
for (const loc of unresolved) {
|
||||
if (loc.advanced_config && !take(loc.advanced_config)) {
|
||||
deny();
|
||||
}
|
||||
}
|
||||
|
||||
// Pass 3: locations that omit advanced_config keep the one at their path, if no location
|
||||
// that names it explicitly has claimed it. Running this last stops a new location at an
|
||||
// old path taking the config away from the location it was renamed to.
|
||||
for (const loc of omitted) {
|
||||
const atPath = advAtPath(loc.path);
|
||||
loc.advanced_config = atPath && take(atPath) ? atPath : "";
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Makes sure that the ssl_* and hsts_* fields play nicely together.
|
||||
* ie: if there is no cert, then force_ssl is off.
|
||||
@@ -79,6 +179,9 @@ const internalHost = {
|
||||
redirection_hosts: [],
|
||||
};
|
||||
|
||||
// No relations are eager-loaded here: this scans every host just to match domains, and
|
||||
// the few that match are rendered through internalNginx.generateConfig(), which loads
|
||||
// their certificate and access list itself (loadRenderRelations).
|
||||
const proxyRes = await proxyHostModel.query().where("is_deleted", 0);
|
||||
responseObject.proxy_hosts = internalHost._getHostsWithDomains(proxyRes, domainNames);
|
||||
responseObject.total_count += responseObject.proxy_hosts.length;
|
||||
|
||||
@@ -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;
|
||||
+164
-25
@@ -1,10 +1,14 @@
|
||||
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 { assertAccessListRenderable, assertHostRenderable, isTokenSafe, unsafeValueError } from "../lib/nginx-values.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import { debug, nginx as logger } from "../logger.js";
|
||||
import accessListModel from "../models/access_list.js";
|
||||
import certificateModel from "../models/certificate.js";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
@@ -34,12 +38,33 @@ 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);
|
||||
return internalNginx.generateConfig(host_type, host).then(
|
||||
() => true,
|
||||
(err) => {
|
||||
if (!err.unsafeNginxValue) {
|
||||
throw err;
|
||||
}
|
||||
// Nothing was written. Report it on the host like a failed nginx test.
|
||||
logger.error(err.message);
|
||||
combined_meta = _.assign({}, host.meta, {
|
||||
nginx_online: false,
|
||||
nginx_err: err.message,
|
||||
});
|
||||
return model
|
||||
.query()
|
||||
.where("id", host.id)
|
||||
.patch({ meta: combined_meta })
|
||||
.then(() => false);
|
||||
},
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
.then((generated) => {
|
||||
if (!generated) {
|
||||
return;
|
||||
}
|
||||
// Test nginx again and update meta with result
|
||||
return internalNginx
|
||||
.test()
|
||||
@@ -83,10 +108,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 +192,38 @@ 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) {
|
||||
assertAccessListRenderable(
|
||||
`Proxy Host #${host.id} location ${locationCopy.path}`,
|
||||
locationAccessList,
|
||||
);
|
||||
locationCopy.access_list = locationAccessList;
|
||||
} else if (
|
||||
locationCopy.access_list_id > 0 &&
|
||||
locationCopy.access_list_id !== host.access_list?.id
|
||||
) {
|
||||
// The location names its own list but it's gone. Don't fall back to the
|
||||
// host's (possibly less restrictive) list; _access.conf denies all instead.
|
||||
logger.error(
|
||||
`Proxy Host #${host.id} location "${locationCopy.path}" references Access List #${locationCopy.access_list_id} which does not exist, all access will be denied`,
|
||||
);
|
||||
locationCopy.access_list = null;
|
||||
} 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 +235,9 @@ const internalNginx = {
|
||||
}
|
||||
};
|
||||
|
||||
locationRendering().then(() => resolve(renderedLocations));
|
||||
locationRendering()
|
||||
.then(() => resolve(renderedLocations))
|
||||
.catch(reject);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -185,11 +246,16 @@ const internalNginx = {
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
generateConfig: (host_type, host_row) => {
|
||||
generateConfig: async (host_type, host_row) => {
|
||||
// Prevent modifying the original object:
|
||||
const host = JSON.parse(JSON.stringify(host_row));
|
||||
const nice_host_type = internalNginx.getFileFriendlyHostType(host_type);
|
||||
|
||||
await internalNginx.loadRenderRelations(nice_host_type, host);
|
||||
|
||||
// Values are written into the config as-is; refuse any that could add directives
|
||||
assertHostRenderable(nice_host_type, host);
|
||||
|
||||
debug(logger, `Generating ${nice_host_type} Config:`, JSON.stringify(host, null, 2));
|
||||
|
||||
const renderEngine = utils.getRenderEngine();
|
||||
@@ -217,10 +283,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);
|
||||
@@ -241,26 +318,79 @@ const internalNginx = {
|
||||
// Set the IPv6 setting for the host
|
||||
host.ipv6 = internalNginx.ipv6Enabled();
|
||||
|
||||
locationsPromise.then(() => {
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then((config_text) => {
|
||||
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
||||
debug(logger, "Wrote config:", filename, config_text);
|
||||
locationsPromise
|
||||
.then(() => {
|
||||
renderEngine
|
||||
.parseAndRender(template, host)
|
||||
.then((config_text) => {
|
||||
fs.writeFileSync(filename, config_text, { encoding: "utf8" });
|
||||
debug(logger, "Wrote config:", filename, config_text);
|
||||
|
||||
// Restore locations array
|
||||
host.locations = origLocations;
|
||||
// Restore locations array
|
||||
host.locations = origLocations;
|
||||
|
||||
resolve(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
debug(logger, `Could not write ${filename}:`, err.message);
|
||||
reject(new errs.ConfigurationError(err.message));
|
||||
});
|
||||
});
|
||||
resolve(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The templates rely on the certificate and access list relations being loaded.
|
||||
* Not every caller fetches them (or fetches them fully), and when they're missing
|
||||
* the config silently renders without SSL or without any access rules (#5919).
|
||||
* Load anything that is referenced but not loaded onto the given host object.
|
||||
*
|
||||
* @param {String} host_type
|
||||
* @param {Object} host
|
||||
* @returns {Promise}
|
||||
*/
|
||||
loadRenderRelations: async (host_type, host) => {
|
||||
if (host.certificate_id > 0 && host.certificate?.id !== host.certificate_id) {
|
||||
const certificate = await certificateModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", host.certificate_id)
|
||||
.first();
|
||||
host.certificate = certificate ? JSON.parse(JSON.stringify(certificate)) : null;
|
||||
}
|
||||
|
||||
if (host_type === "proxy_host" && host.access_list_id > 0) {
|
||||
if (
|
||||
host.access_list?.id !== host.access_list_id ||
|
||||
!Array.isArray(host.access_list.clients) ||
|
||||
!Array.isArray(host.access_list.items)
|
||||
) {
|
||||
const accessList = await accessListModel
|
||||
.query()
|
||||
.where("is_deleted", 0)
|
||||
.andWhere("id", host.access_list_id)
|
||||
.withGraphFetched("[clients,items]")
|
||||
.first();
|
||||
host.access_list = accessList ? JSON.parse(JSON.stringify(accessList)) : null;
|
||||
}
|
||||
|
||||
if (!host.access_list) {
|
||||
// _access.conf renders "deny all" when the list is missing, so fail closed
|
||||
logger.error(
|
||||
`Proxy Host #${host.id} references Access List #${host.access_list_id} which does not exist, all access will be denied`,
|
||||
);
|
||||
} else if (!host.access_list.clients.length && !host.access_list.items.length) {
|
||||
logger.warn(
|
||||
`Proxy Host #${host.id} uses Access List #${host.access_list_id} which has no clients or authorizations, no access restrictions will apply`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* This generates a temporary nginx config listening on port 80 for the domain names listed
|
||||
* in the certificate setup. It allows the letsencrypt acme challenge to be requested by letsencrypt
|
||||
@@ -273,6 +403,15 @@ const internalNginx = {
|
||||
debug(logger, "Generating LetsEncrypt Request Config:", certificate);
|
||||
const renderEngine = utils.getRenderEngine();
|
||||
|
||||
const unsafe = (certificate.domain_names || []).find((name) => !isTokenSafe(name));
|
||||
if (typeof unsafe !== "undefined") {
|
||||
return Promise.reject(
|
||||
unsafeValueError(
|
||||
`Certificate #${certificate.id}: domain name ${JSON.stringify(unsafe)} is not allowed`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let template = null;
|
||||
const filename = `/data/nginx/temp/letsencrypt_${certificate.id}.conf`;
|
||||
@@ -375,8 +514,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();
|
||||
|
||||
@@ -2,7 +2,7 @@ import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import { castJsonIfNeed } from "../lib/helpers.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
import internalCertificate from "./certificate.js";
|
||||
import internalHost from "./host.js";
|
||||
@@ -57,6 +57,8 @@ const internalProxyHost = {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
})
|
||||
.then((row) => {
|
||||
@@ -83,7 +85,7 @@ const internalProxyHost = {
|
||||
expand: ["certificate", "owner", "access_list.[clients,items]"],
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
.then(async (row) => {
|
||||
// Configure nginx
|
||||
return internalNginx.configure(proxyHostModel, "proxy_host", row).then(() => {
|
||||
return row;
|
||||
@@ -155,6 +157,9 @@ const internalProxyHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
|
||||
|
||||
if (createCertificate) {
|
||||
return internalCertificate
|
||||
.createQuickCertificate(access, {
|
||||
@@ -208,7 +213,7 @@ const internalProxyHost = {
|
||||
id: thisData.id,
|
||||
expand: ["owner", "certificate", "access_list.[clients,items]"],
|
||||
})
|
||||
.then((row) => {
|
||||
.then(async (row) => {
|
||||
if (!row.enabled) {
|
||||
// No need to add nginx config if host is disabled
|
||||
return row;
|
||||
@@ -243,7 +248,7 @@ const internalProxyHost = {
|
||||
.first();
|
||||
|
||||
if (access_data.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
|
||||
}
|
||||
|
||||
if (typeof thisData.expand !== "undefined" && thisData.expand !== null) {
|
||||
@@ -323,10 +328,10 @@ const internalProxyHost = {
|
||||
.then(() => {
|
||||
return internalProxyHost.get(access, {
|
||||
id: data.id,
|
||||
expand: ["certificate", "owner", "access_list"],
|
||||
expand: ["certificate", "owner", "access_list.[clients,items]"],
|
||||
});
|
||||
})
|
||||
.then((row) => {
|
||||
.then(async (row) => {
|
||||
if (!row?.id) {
|
||||
throw new errs.ItemNotFoundError(data.id);
|
||||
}
|
||||
@@ -336,27 +341,21 @@ const internalProxyHost = {
|
||||
|
||||
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(() => {
|
||||
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;
|
||||
});
|
||||
},
|
||||
@@ -430,7 +429,7 @@ const internalProxyHost = {
|
||||
.orderBy(castJsonIfNeed("domain_names"), "ASC");
|
||||
|
||||
if (accessData.permission_visibility !== "all") {
|
||||
query.andWhere("owner_user_id", access.token.getUserId(1));
|
||||
scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta);
|
||||
}
|
||||
|
||||
// Query is used for searching
|
||||
@@ -456,18 +455,18 @@ const internalProxyHost = {
|
||||
*
|
||||
* @param {Number} user_id
|
||||
* @param {String} visibility
|
||||
* @param {Object} [permissionsMeta] the user's permissions meta, as already loaded by access
|
||||
* @returns {Promise}
|
||||
*/
|
||||
getCount: (user_id, visibility) => {
|
||||
getCount: async (user_id, visibility, permissionsMeta) => {
|
||||
const query = proxyHostModel.query().count("id as count").where("is_deleted", 0);
|
||||
|
||||
if (visibility !== "all") {
|
||||
query.andWhere("owner_user_id", user_id);
|
||||
scopeToOwnerOrAllowed(query, user_id, permissionsMeta);
|
||||
}
|
||||
|
||||
return query.first().then((row) => {
|
||||
return Number.parseInt(row.count, 10);
|
||||
});
|
||||
const row = await query.first();
|
||||
return Number.parseInt(row.count, 10);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -53,10 +53,12 @@ const internalRedirectionHost = {
|
||||
|
||||
// Fix for db field not having a default value
|
||||
// for this optional field.
|
||||
if (typeof data.advanced_config === "undefined") {
|
||||
data.advanced_config = "";
|
||||
if (typeof thisData.advanced_config === "undefined") {
|
||||
thisData.advanced_config = "";
|
||||
}
|
||||
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData);
|
||||
|
||||
return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
|
||||
})
|
||||
.then((row) => {
|
||||
@@ -155,6 +157,9 @@ const internalRedirectionHost = {
|
||||
);
|
||||
}
|
||||
|
||||
// Before the certificate is created, so a refused request doesn't leave one behind
|
||||
internalHost.assertAdvancedConfigAllowed(access, thisData, row);
|
||||
|
||||
if (createCertificate) {
|
||||
return internalCertificate
|
||||
.createQuickCertificate(access, {
|
||||
|
||||
@@ -15,7 +15,11 @@ const internalReport = {
|
||||
const userId = access.token.getUserId(1);
|
||||
|
||||
const promises = [
|
||||
internalProxyHost.getCount(userId, access_data.permission_visibility),
|
||||
internalProxyHost.getCount(
|
||||
userId,
|
||||
access_data.permission_visibility,
|
||||
access.getPermissions?.()?.meta,
|
||||
),
|
||||
internalRedirectionHost.getCount(userId, access_data.permission_visibility),
|
||||
internalStream.getCount(userId, access_data.permission_visibility),
|
||||
internalDeadHost.getCount(userId, access_data.permission_visibility),
|
||||
|
||||
+12
-20
@@ -34,26 +34,21 @@ export default {
|
||||
.andWhere("is_disabled", 0)
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
}
|
||||
|
||||
// An unknown, disabled or passwordless account goes through the same queries and the same password check
|
||||
// as a real one, and fails with the same error: answering any sooner or any differently would tell anyone
|
||||
// trying addresses which ones have an account. There is no user 0, so its lookup finds nothing. An empty
|
||||
// secret, which creating a user through the API can store, is no hash: bcrypt would reject it at once.
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", "=", user.id)
|
||||
.where("user_id", "=", user?.id ?? 0)
|
||||
.where("type", "=", "password")
|
||||
.first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.AuthError(ERROR_MESSAGE_INVALID_AUTH);
|
||||
}
|
||||
|
||||
const valid = await auth.verifyPassword(data.secret);
|
||||
if (!valid) {
|
||||
throw new errs.AuthError(
|
||||
ERROR_MESSAGE_INVALID_AUTH,
|
||||
ERROR_MESSAGE_INVALID_AUTH_I18N,
|
||||
);
|
||||
const valid = auth?.secret
|
||||
? await auth.verifyPassword(data.secret)
|
||||
: await authModel.verifyDummyPassword(data.secret);
|
||||
if (!user || !valid) {
|
||||
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 +166,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 +178,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
|
||||
|
||||
+51
-10
@@ -3,6 +3,7 @@ import _ from "lodash";
|
||||
import errs from "../lib/error.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import authModel from "../models/auth.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import userModel from "../models/user.js";
|
||||
import userPermissionModel from "../models/user_permission.js";
|
||||
import internalAuditLog from "./audit-log.js";
|
||||
@@ -257,11 +258,9 @@ const internalUser = {
|
||||
},
|
||||
|
||||
deleteAll: async () => {
|
||||
await userModel
|
||||
.query()
|
||||
.patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
await userModel.query().patch({
|
||||
is_deleted: 1,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -390,11 +389,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 +411,7 @@ const internalUser = {
|
||||
user_id: user.id,
|
||||
type: data.type,
|
||||
secret: data.secret,
|
||||
meta: {},
|
||||
meta: { password_changed_at },
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
@@ -436,7 +445,7 @@ const internalUser = {
|
||||
return internalUser.get(access, { id: data.id });
|
||||
})
|
||||
.then((user) => {
|
||||
if (user.id !== data.id) {
|
||||
if (Number(user.id) !== Number(data.id)) {
|
||||
// Sanity check that something crazy hasn't happened
|
||||
throw new errs.InternalValidationError(
|
||||
`User could not be updated, IDs do not match: ${user.id} !== ${data.id}`,
|
||||
@@ -446,21 +455,23 @@ const internalUser = {
|
||||
return user;
|
||||
})
|
||||
.then((user) => {
|
||||
const permData = _.omit(data, ["id"]);
|
||||
// Get perms row, patch if it exists
|
||||
return userPermissionModel
|
||||
.query()
|
||||
.where("user_id", user.id)
|
||||
.first()
|
||||
.then((existing_auth) => {
|
||||
.then(async (existing_auth) => {
|
||||
permData.meta = await internalUser.normalisePermissionMeta(existing_auth, permData);
|
||||
if (existing_auth) {
|
||||
// patch
|
||||
return userPermissionModel
|
||||
.query()
|
||||
.where("user_id", user.id)
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, data));
|
||||
.patchAndFetchById(existing_auth.id, _.assign({ user_id: user.id }, permData));
|
||||
}
|
||||
// insert
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, data));
|
||||
return userPermissionModel.query().insertAndFetch(_.assign({ user_id: user.id }, permData));
|
||||
})
|
||||
.then((permissions) => {
|
||||
// Add to Audit Log
|
||||
@@ -480,6 +491,36 @@ const internalUser = {
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* The permission meta to store. Proxy host grants (meta.proxy_host_ids) only apply while
|
||||
* visibility is "user" and proxy hosts are not hidden, so they're cleared otherwise; kept,
|
||||
* they would silently come back when proxy hosts are shown again. Ids of hosts that no
|
||||
* longer exist are dropped. `permData` may be partial, so the stored values fill the gaps.
|
||||
*
|
||||
* @param {Object} [existing] the stored user_permission row
|
||||
* @param {Object} permData incoming permission fields
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
normalisePermissionMeta: async (existing, permData) => {
|
||||
const effective = _.assign(
|
||||
{ visibility: "user", proxy_hosts: "hidden" },
|
||||
existing || {},
|
||||
_.omit(permData, ["meta"]),
|
||||
);
|
||||
const meta = _.assign({}, existing?.meta || {}, permData.meta || {});
|
||||
const requested = Array.isArray(meta.proxy_host_ids) ? _.uniq(meta.proxy_host_ids) : [];
|
||||
|
||||
if (effective.visibility !== "user" || effective.proxy_hosts === "hidden" || !requested.length) {
|
||||
meta.proxy_host_ids = [];
|
||||
return meta;
|
||||
}
|
||||
|
||||
const rows = await proxyHostModel.query().select("id").where("is_deleted", 0).whereIn("id", requested);
|
||||
const found = new Set(rows.map((r) => r.id));
|
||||
meta.proxy_host_ids = requested.filter((id) => found.has(id));
|
||||
return meta;
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Access} access
|
||||
* @param {Object} data
|
||||
|
||||
+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",
|
||||
},
|
||||
},
|
||||
};
|
||||
+34
-8
@@ -12,7 +12,8 @@ import { fileURLToPath } from "node:url";
|
||||
import Ajv from "ajv/dist/2020.js";
|
||||
import _ from "lodash";
|
||||
import { access as logger } from "../logger.js";
|
||||
import proxyHostModel from "../models/proxy_host.js";
|
||||
import authModel from "../models/auth.js";
|
||||
import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js";
|
||||
import TokenModel from "../models/token.js";
|
||||
import userModel from "../models/user.js";
|
||||
import permsSchema from "./access/permissions.json" with { type: "json" };
|
||||
@@ -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,13 +136,10 @@ 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);
|
||||
scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta);
|
||||
}
|
||||
|
||||
const rows = await query;
|
||||
@@ -199,6 +213,11 @@ export default function (tokenString) {
|
||||
return {
|
||||
token: Token,
|
||||
|
||||
getPermissions: () => permissions,
|
||||
getRoles: () => userRoles,
|
||||
hasRole: (role) => userRoles.includes(role),
|
||||
isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"),
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Boolean} [allowInternal]
|
||||
@@ -206,7 +225,8 @@ export default function (tokenString) {
|
||||
*/
|
||||
load: async (allowInternal) => {
|
||||
if (tokenString) {
|
||||
return await Token.load(tokenString);
|
||||
await this.init();
|
||||
return tokenData;
|
||||
}
|
||||
allowInternalAccess = allowInternal;
|
||||
return allowInternal || null;
|
||||
@@ -241,6 +261,7 @@ export default function (tokenString) {
|
||||
permission_streams: permissions.streams,
|
||||
permission_access_lists: permissions.access_lists,
|
||||
permission_certificates: permissions.certificates,
|
||||
permission_meta: permissions.meta,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -271,7 +292,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) => {
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isCI } from "../config.js";
|
||||
|
||||
/**
|
||||
* In-memory fixed window rate limiter for authentication endpoints
|
||||
*/
|
||||
|
||||
// Keys longer than this are hashed, so a client can't make the store hold arbitrarily large
|
||||
// strings (keys can include request body values such as the login identity).
|
||||
const MAX_KEY_LENGTH = 128;
|
||||
// Entries per limiter. A flood of unique keys sweeps expired entries, then evicts the oldest,
|
||||
// instead of growing the store until the periodic cleanup runs.
|
||||
const MAX_STORE_SIZE = 50000;
|
||||
|
||||
// One store per limiter, so separate endpoints never share buckets
|
||||
const stores = new Set();
|
||||
|
||||
// Clean up old entries every 2 minutes
|
||||
const cleanupTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const store of stores) {
|
||||
for (const [key, record] of store.entries()) {
|
||||
if (now - record.startTime > record.windowMs * 2) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 120000);
|
||||
cleanupTimer.unref();
|
||||
|
||||
// Generated at container start (prepare/45-admin-port.sh) and sent by the admin nginx as
|
||||
// X-NPM-Peer-Secret alongside X-NPM-Peer-Addr. Unset (development, or a mounted production.conf)
|
||||
// means the header is never trusted.
|
||||
const peerSecret = Buffer.from(process.env.NPM_PEER_SECRET || "");
|
||||
|
||||
const hasPeerSecret = (value) => {
|
||||
if (!peerSecret.length || typeof value !== "string") {
|
||||
return false;
|
||||
}
|
||||
const given = Buffer.from(value);
|
||||
return given.length === peerSecret.length && crypto.timingSafeEqual(given, peerSecret);
|
||||
};
|
||||
|
||||
/**
|
||||
* The address to rate limit on: the TCP peer of the admin nginx, which it passes as
|
||||
* X-NPM-Peer-Addr ($realip_remote_addr). That header is only honoured from a loopback socket
|
||||
* carrying the startup secret, so it can only have come from the admin nginx: a proxy host or
|
||||
* stream forwarding to the backend port is also a loopback peer, but can't know the secret.
|
||||
* The realip-resolved client address is deliberately not used: nginx trusts X-Real-IP from
|
||||
* private ranges, so a client on the LAN or Docker network could pick a new address, and a fresh
|
||||
* bucket, on every request. Behind another proxy (including an NPM proxy host in front of the
|
||||
* admin UI) this resolves to that proxy's address.
|
||||
*
|
||||
* @param {Object} req
|
||||
* @returns {String}
|
||||
*/
|
||||
export const getClientIp = (req) => {
|
||||
const socketAddr = req.socket?.remoteAddress || "unknown";
|
||||
const isLoopback = socketAddr === "127.0.0.1" || socketAddr === "::1" || socketAddr === "::ffff:127.0.0.1";
|
||||
const trusted = isLoopback && hasPeerSecret(req.headers["x-npm-peer-secret"]);
|
||||
const peerAddr = trusted ? req.headers["x-npm-peer-addr"]?.trim() : null;
|
||||
return peerAddr || socketAddr;
|
||||
};
|
||||
|
||||
const makeRoom = (store, now) => {
|
||||
for (const [k, r] of store.entries()) {
|
||||
if (now - r.startTime > r.windowMs) {
|
||||
store.delete(k);
|
||||
}
|
||||
}
|
||||
// Maps iterate in insertion order, so this drops the oldest windows first
|
||||
for (const k of store.keys()) {
|
||||
if (store.size < MAX_STORE_SIZE) {
|
||||
break;
|
||||
}
|
||||
store.delete(k);
|
||||
}
|
||||
};
|
||||
|
||||
const boundedKey = (key) =>
|
||||
key.length > MAX_KEY_LENGTH ? `sha256:${crypto.createHash("sha256").update(key).digest("hex")}` : key;
|
||||
|
||||
/**
|
||||
* @param {Object} [options]
|
||||
* @param {Number} [options.windowMs]
|
||||
* @param {Number} [options.max]
|
||||
* @param {String} [options.message]
|
||||
* @param {Boolean} [options.skipSuccessfulRequests]
|
||||
* @param {Function} [options.keyGenerator] (req, clientIp) => String. Defaults to the client IP.
|
||||
* Including the target account in the key stops every
|
||||
* user being locked out when many clients share an IP
|
||||
* (e.g. the admin UI behind another reverse proxy).
|
||||
*/
|
||||
export default function createRateLimiter(options = {}) {
|
||||
const windowMs = options.windowMs || 60 * 1000; // 1 minute default
|
||||
const max = options.max || 10; // 10 requests default
|
||||
const message = options.message || "Too many attempts from this IP, please try again later.";
|
||||
const skipSuccessfulRequests = options.skipSuccessfulRequests !== false;
|
||||
const keyGenerator = options.keyGenerator || ((_req, clientIp) => clientIp);
|
||||
|
||||
const store = new Map();
|
||||
stores.add(store);
|
||||
|
||||
return (req, res, next) => {
|
||||
// Bypass rate limiting in CI, test environments, or when explicitly disabled
|
||||
if (
|
||||
isCI() ||
|
||||
process.env.CI === "true" ||
|
||||
process.env.NODE_ENV === "test" ||
|
||||
process.env.DISABLE_RATE_LIMIT === "true"
|
||||
) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const key = boundedKey(String(keyGenerator(req, getClientIp(req))));
|
||||
|
||||
const now = Date.now();
|
||||
let record = store.get(key);
|
||||
|
||||
if (!record || now - record.startTime > windowMs) {
|
||||
record = {
|
||||
count: 0,
|
||||
startTime: now,
|
||||
windowMs,
|
||||
};
|
||||
if (!store.has(key) && store.size >= MAX_STORE_SIZE) {
|
||||
makeRoom(store, now);
|
||||
}
|
||||
store.set(key, record);
|
||||
}
|
||||
|
||||
record.count++;
|
||||
|
||||
if (skipSuccessfulRequests) {
|
||||
const currentRecord = record;
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode < 400 && currentRecord.count > 0) {
|
||||
currentRecord.count--;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (record.count > max) {
|
||||
res.setHeader("Retry-After", Math.ceil((record.startTime + windowMs - now) / 1000));
|
||||
return res.status(429).json({
|
||||
error: {
|
||||
code: 429,
|
||||
message,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import net from "node:net";
|
||||
import errs from "./error.js";
|
||||
|
||||
/**
|
||||
* Rules for user-supplied values that the templates write into nginx config as-is.
|
||||
*
|
||||
* Anything that ends or changes an nginx token (whitespace, ";", "{", "}", quotes, "#" which
|
||||
* starts a comment, and "\" which escapes the next character) would let the value add its own
|
||||
* directives, which only admins may do (advanced_config). The API schema rejects these values
|
||||
* with the same patterns (a test keeps the two in step). generateConfig() checks stored values
|
||||
* again, so rows saved before these rules can't add directives either. That check is only about
|
||||
* nginx structure, not format: a stored value in a format the API no longer accepts, but which
|
||||
* can't add directives, still renders.
|
||||
*
|
||||
* Patterns are strings so they can be compared with the JSON schema; compile them with the "u"
|
||||
* flag, as ajv does.
|
||||
*/
|
||||
|
||||
// One character that can't end or change an unquoted nginx token
|
||||
const TOKEN_CHAR = String.raw`[^\s\p{Cc};{}"'#\\]`;
|
||||
// Host part of a forward host: also no "$" (no variables in the upstream address) and no "/"
|
||||
const HOST_CHARS = String.raw`[^\s\p{Cc};{}"'#\\$/]+`;
|
||||
// The existing domain name rule ("a.b.c", no "*", no empty labels), written so it can't
|
||||
// backtrack exponentially
|
||||
const DOMAIN = String.raw`[^.*]+(?:\.[^.*]+)*\.?[^.]`;
|
||||
|
||||
export const patterns = {
|
||||
// server_name entries. The old character blacklist plus whitespace and control characters.
|
||||
domainName: "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$",
|
||||
// proxy_host forward_host and location forward_host: a host, optionally followed by a path
|
||||
// (locations split "host/path" at render time)
|
||||
forwardHost: String.raw`^${HOST_CHARS}(?:/${TOKEN_CHAR}*)?$`,
|
||||
// location forward_path, appended straight after the port
|
||||
forwardPath: String.raw`^${TOKEN_CHAR}*$`,
|
||||
// location path: an optional modifier, then one token, either unquoted (a "\" must escape a
|
||||
// visible character, so it can't swallow the space before "{") or double quoted. A modifier
|
||||
// on its own isn't a location.
|
||||
locationPath: String.raw`^(?!(?:=|~\*?|\^~)$)(?:(?:=|~\*?|\^~) +)?(?:"(?:[^"\\\p{Cc}]|\\[^\p{Cc}])+"|(?:[^\s\p{Cc};{}"'#\\]|\\[^\s\p{Cc}])+)$`,
|
||||
// redirection forward_domain_name, e.g. "example.com:8443/path"
|
||||
redirectDomain: String.raw`^(?=${TOKEN_CHAR}+$)${DOMAIN}$`,
|
||||
// stream forwarding_host when it is a domain name (IPs are matched separately)
|
||||
streamDomain: String.raw`^(?=${HOST_CHARS}$)${DOMAIN}$`,
|
||||
// access list client address: IPv6 with an optional prefix. No zone id ("%eth0"): nginx's
|
||||
// allow/deny rejects them, and the old "%.+" let anything through, including "; allow all"
|
||||
clientIpv6: String.raw`^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$`,
|
||||
// htpasswd user name: a ":" or a line break would corrupt the file
|
||||
accessUsername: String.raw`^[^:\p{Cc}]+$`,
|
||||
};
|
||||
|
||||
const re = Object.fromEntries(Object.entries(patterns).map(([k, v]) => [k, new RegExp(v, "u")]));
|
||||
const unsafeToken = /[\s\p{Cc};{}"'#\\]/u;
|
||||
const ipv4WithPrefix = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(?:\/(?:[0-9]|[1-2][0-9]|3[0-2]))?$/;
|
||||
|
||||
export const isValid = (name, value) => typeof value === "string" && re[name].test(value);
|
||||
|
||||
export const isValidClientAddress = (value) =>
|
||||
typeof value === "string" && (value === "all" || ipv4WithPrefix.test(value) || re.clientIpv6.test(value));
|
||||
|
||||
export const isValidStreamHost = (value) =>
|
||||
typeof value === "string" && (net.isIP(value) !== 0 || re.streamDomain.test(value));
|
||||
|
||||
/**
|
||||
* Thrown when a stored value can't be written into nginx config safely
|
||||
*
|
||||
* @param {String} message
|
||||
*/
|
||||
export const unsafeValueError = (message) => {
|
||||
const err = new errs.ConfigurationError(message);
|
||||
err.unsafeNginxValue = true;
|
||||
return err;
|
||||
};
|
||||
|
||||
const describe = (value) => JSON.stringify(String(value)).slice(0, 80);
|
||||
|
||||
/**
|
||||
* @param {String} label e.g. "Proxy Host #3"
|
||||
* @param {Object} [accessList]
|
||||
* @throws {ConfigurationError}
|
||||
*/
|
||||
export const assertAccessListRenderable = (label, accessList) => {
|
||||
for (const client of accessList?.clients || []) {
|
||||
// "address" is rendered unquoted ("allow <address>;")
|
||||
if (typeof client.address !== "string" || unsafeToken.test(client.address)) {
|
||||
throw unsafeValueError(`${label}: access list client address ${describe(client.address)} is not allowed`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Can be written into config without ending or changing a token
|
||||
export const isTokenSafe = (value) => typeof value === "string" && value.length > 0 && !unsafeToken.test(value);
|
||||
|
||||
/**
|
||||
* Checks every user-supplied value the host's template renders. Call before rendering.
|
||||
*
|
||||
* @param {String} hostType proxy_host, redirection_host, dead_host, stream or default
|
||||
* @param {Object} host with access_list loaded, for proxy hosts
|
||||
* @throws {ConfigurationError}
|
||||
*/
|
||||
export const assertHostRenderable = (hostType, host) => {
|
||||
const label = `${hostType.replace(/_/g, " ")} #${host.id}`;
|
||||
const check = (ok, what, value) => {
|
||||
if (!ok) {
|
||||
throw unsafeValueError(`${label}: ${what} ${describe(value)} is not allowed in nginx config`);
|
||||
}
|
||||
};
|
||||
|
||||
if (hostType !== "stream" && hostType !== "default") {
|
||||
for (const name of host.domain_names || []) {
|
||||
check(isTokenSafe(name), "domain name", name);
|
||||
}
|
||||
}
|
||||
|
||||
if (hostType === "proxy_host") {
|
||||
check(isTokenSafe(host.forward_host), "forward host", host.forward_host);
|
||||
assertAccessListRenderable(label, host.access_list);
|
||||
for (const loc of host.locations || []) {
|
||||
// The path is several tokens (modifier, then a quoted or unquoted URI): its rule is the
|
||||
// structural one
|
||||
check(isValid("locationPath", loc.path), "location path", loc.path);
|
||||
check(isTokenSafe(loc.forward_host), "location forward host", loc.forward_host);
|
||||
if (loc.forward_path) {
|
||||
check(isTokenSafe(loc.forward_path), "location forward path", loc.forward_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostType === "redirection_host") {
|
||||
check(isTokenSafe(host.forward_domain_name), "forward domain", host.forward_domain_name);
|
||||
}
|
||||
|
||||
if (hostType === "stream") {
|
||||
check(isTokenSafe(host.forwarding_host), "forwarding host", host.forwarding_host);
|
||||
}
|
||||
};
|
||||
@@ -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,24 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Keep ajv's wording, which clients may match on, but not the regex it quotes: some are long
|
||||
// and mean nothing to a user
|
||||
for (const e of validate.errors || []) {
|
||||
if (e.keyword === "pattern") {
|
||||
e.message = "must match pattern";
|
||||
}
|
||||
}
|
||||
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 };
|
||||
@@ -0,0 +1,41 @@
|
||||
import { migrate as logger } from "../logger.js";
|
||||
|
||||
const migrateName = "user_permission_meta";
|
||||
|
||||
/**
|
||||
* Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const up = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Up...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.json("meta").nullable();
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Undo Migrate
|
||||
*
|
||||
* @param {Object} knex
|
||||
* @returns {Promise}
|
||||
*/
|
||||
const down = (knex) => {
|
||||
logger.info(`[${migrateName}] Migrating Down...`);
|
||||
|
||||
return knex.schema
|
||||
.alterTable("user_permission", (table) => {
|
||||
table.dropColumn("meta");
|
||||
})
|
||||
.then(() => {
|
||||
logger.info(`[${migrateName}] user_permission Table altered`);
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -1,6 +1,7 @@
|
||||
// Objection Docs:
|
||||
// http://vincit.github.io/objection.js/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import bcrypt from "bcrypt";
|
||||
import { Model } from "objection";
|
||||
import db from "../db.js";
|
||||
@@ -11,10 +12,15 @@ import User from "./user.js";
|
||||
Model.knex(db());
|
||||
|
||||
const boolFields = ["is_deleted"];
|
||||
const saltRounds = 13;
|
||||
|
||||
// A hash of a random value, for a login that names an account with no password to check it against. Hashed
|
||||
// with the same cost as real passwords, and made at startup so the first such login takes no longer either.
|
||||
const dummyHash = bcrypt.hash(crypto.randomBytes(32).toString("hex"), saltRounds);
|
||||
|
||||
function encryptPassword() {
|
||||
if (this.type === "password" && this.secret) {
|
||||
return bcrypt.hash(this.secret, 13).then((hash) => {
|
||||
return bcrypt.hash(this.secret, saltRounds).then((hash) => {
|
||||
this.secret = hash;
|
||||
});
|
||||
}
|
||||
@@ -60,6 +66,18 @@ class Auth extends Model {
|
||||
return bcrypt.compare(password, this.secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Take as long as verifyPassword does, then fail. bcrypt is nearly all of the time a login takes, so a login
|
||||
* that skipped it for an unknown account would answer far sooner, and say which accounts exist.
|
||||
*
|
||||
* @param {String} password
|
||||
* @returns {Promise<false>}
|
||||
*/
|
||||
static async verifyDummyPassword(password) {
|
||||
await bcrypt.compare(password, await dummyHash);
|
||||
return false;
|
||||
}
|
||||
|
||||
static get name() {
|
||||
return "Auth";
|
||||
}
|
||||
|
||||
@@ -124,4 +124,23 @@ class ProxyHost extends Model {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts a proxy host query to hosts the user owns or has been granted by id
|
||||
* (user permission meta.proxy_host_ids).
|
||||
*
|
||||
* @param {Object} query objection query on proxy_host
|
||||
* @param {Number} userId
|
||||
* @param {Object} [permissionMeta] the user's permission meta
|
||||
* @returns {Object} the query
|
||||
*/
|
||||
export const scopeToOwnerOrAllowed = (query, userId, permissionMeta) => {
|
||||
const allowedIds = permissionMeta?.proxy_host_ids || [];
|
||||
return query.andWhere((builder) => {
|
||||
builder.where("owner_user_id", userId);
|
||||
if (allowedIds.length > 0) {
|
||||
builder.orWhereIn("id", allowedIds);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default ProxyHost;
|
||||
@@ -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,25 @@ 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";
|
||||
}
|
||||
|
||||
static get jsonAttributes() {
|
||||
return ["meta"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-18
@@ -8,42 +8,43 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "biome lint",
|
||||
"test": "node --test test/",
|
||||
"prettier": "biome format --write .",
|
||||
"validate-schema": "node validate-schema.js",
|
||||
"regenerate-config": "node scripts/regenerate-config"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apidevtools/json-schema-ref-parser": "^15.3.5",
|
||||
"@apidevtools/json-schema-ref-parser": "^16.0.6",
|
||||
"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",
|
||||
"chalk": "5.6.2",
|
||||
"compression": "^1.8.1",
|
||||
"express": "^5.2.1",
|
||||
"express-fileupload": "^1.5.2",
|
||||
"better-sqlite3": "^13.0.3",
|
||||
"body-parser": "^2.4.0",
|
||||
"chalk": "6.0.1",
|
||||
"compression": "^1.8.2",
|
||||
"express": "^5.3.0",
|
||||
"express-fileupload": "^1.6.0",
|
||||
"gravatar": "^1.8.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"knex": "3.2.10",
|
||||
"liquidjs": "10.27.0",
|
||||
"knex": "3.3.0",
|
||||
"liquidjs": "10.30.1",
|
||||
"lodash": "^4.18.1",
|
||||
"moment": "^2.30.1",
|
||||
"mysql2": "^3.22.3",
|
||||
"node-rsa": "^1.1.1",
|
||||
"objection": "3.1.5",
|
||||
"otplib": "^13.4.0",
|
||||
"moment": "^2.31.0",
|
||||
"mysql2": "^3.24.6",
|
||||
"node-rsa": "^2.0.0",
|
||||
"objection": "4.4.3",
|
||||
"otplib": "^13.6.0",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.21.0",
|
||||
"proxy-agent": "^8.0.1",
|
||||
"pg": "^8.23.1",
|
||||
"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.1.0",
|
||||
"@biomejs/biome": "^2.6.6",
|
||||
"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,112 @@
|
||||
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}`);
|
||||
@@ -180,7 +161,7 @@ router
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await internalCertificate.validate({
|
||||
const result = await internalCertificate.validate(res.locals.access, {
|
||||
files: req.files,
|
||||
});
|
||||
res.status(200).send(result);
|
||||
@@ -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;
|
||||
@@ -1,10 +1,56 @@
|
||||
import express from "express";
|
||||
import internalToken from "../internal/token.js";
|
||||
import jwtdecode from "../lib/express/jwt-decode.js";
|
||||
import createRateLimiter from "../lib/express/rate-limit.js";
|
||||
import apiValidator from "../lib/validator/api.js";
|
||||
import { debug, express as logger } from "../logger.js";
|
||||
import { getValidationSchema } from "../schema/index.js";
|
||||
|
||||
// Two limits apply to logins. Per IP + target account: when the admin UI sits behind another
|
||||
// proxy every client can resolve to the same IP, and an IP-only bucket at this size would let
|
||||
// anyone lock out every user with a handful of bad logins.
|
||||
const tokenRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req, clientIp) => {
|
||||
const identity = typeof req.body?.identity === "string" ? req.body.identity.trim().toLowerCase() : "";
|
||||
return `${clientIp}|${identity}`;
|
||||
},
|
||||
});
|
||||
|
||||
// And per IP across all accounts, so one address can't spray passwords over many accounts at
|
||||
// 10 a minute each. Only failures count. Behind a shared proxy this cap is shared by everyone
|
||||
// using it, which is why it's higher than the per-account one.
|
||||
const tokenIpRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 30,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
});
|
||||
|
||||
// Keyed on the user the challenge belongs to, not the challenge token itself, so fetching
|
||||
// fresh challenge tokens doesn't reset the code-guessing budget. The payload is read without
|
||||
// verifying the signature; verify2FA does that. A forged token only affects buckets for the
|
||||
// sender's own IP.
|
||||
const challengeUserId = (token) => {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(String(token).split(".")[1], "base64url").toString());
|
||||
return String(payload?.attrs?.id ?? "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const twoFactorRateLimiter = createRateLimiter({
|
||||
windowMs: 60 * 1000,
|
||||
max: 10,
|
||||
message: "Too many login attempts. Please try again in 1 minute.",
|
||||
skipSuccessfulRequests: true,
|
||||
keyGenerator: (req, clientIp) => `${clientIp}|${challengeUserId(req.body?.challenge_token)}`,
|
||||
});
|
||||
|
||||
const router = express.Router({
|
||||
caseSensitive: true,
|
||||
strict: true,
|
||||
@@ -42,7 +88,7 @@ router
|
||||
*
|
||||
* Create a new Token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(tokenIpRateLimiter, tokenRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const data = await apiValidator(getValidationSchema("/tokens", "post"), req.body);
|
||||
const result = await internalToken.getTokenFromEmail(data);
|
||||
@@ -64,7 +110,7 @@ router
|
||||
*
|
||||
* Verify 2FA code and get full token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
.post(twoFactorRateLimiter, async (req, res, next) => {
|
||||
try {
|
||||
const { challenge_token, code } = await apiValidator(getValidationSchema("/tokens/2fa", "post"), req.body);
|
||||
const result = await internalToken.verify2FA(challenge_token, code);
|
||||
|
||||
+11
-42
@@ -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,
|
||||
);
|
||||
payload.id = req.params.user_id;
|
||||
const result = await internalUser.setPermissions(
|
||||
res.locals.access,
|
||||
payload,
|
||||
);
|
||||
const payload = await apiValidator(getValidationSchema("/users/{userID}/permissions", "put"), req.body);
|
||||
payload.id = Number.parseInt(req.params.user_id, 10);
|
||||
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) {
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
|
||||
},
|
||||
"example": ["example.com", "www.example.com"]
|
||||
},
|
||||
@@ -147,7 +147,7 @@
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]d|1dd|[1-9]?d)(.(25[0-5]|2[0-4]d|1dd|[1-9]?d)){3}))|:)))(%.+)?s*(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$"
|
||||
"pattern": "^(?:(?:[0-9A-Fa-f]{1,4}:){7}(?:[0-9A-Fa-f]{1,4}|:)|(?:[0-9A-Fa-f]{1,4}:){6}(?::[0-9A-Fa-f]{1,4}|(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){5}(?:(?::[0-9A-Fa-f]{1,4}){1,2}|:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){4}(?:(?::[0-9A-Fa-f]{1,4}){1,3}|(?::[0-9A-Fa-f]{1,4})?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){3}(?:(?::[0-9A-Fa-f]{1,4}){1,4}|(?::[0-9A-Fa-f]{1,4}){0,2}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:){2}(?:(?::[0-9A-Fa-f]{1,4}){1,5}|(?::[0-9A-Fa-f]{1,4}){0,3}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|(?:[0-9A-Fa-f]{1,4}:)(?:(?::[0-9A-Fa-f]{1,4}){1,6}|(?::[0-9A-Fa-f]{1,4}){0,4}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:)|:(?:(?::[0-9A-Fa-f]{1,4}){1,7}|(?::[0-9A-Fa-f]{1,4}){0,5}:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?:\\.(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])){3}|:))(?:/(?:[0-9]|[1-9][0-9]|1[01][0-9]|12[0-8]))?$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -164,7 +164,8 @@
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"pattern": "^[^:\\p{Cc}]+$"
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"]+$"
|
||||
"pattern": "^[^&| @!#%^();:/\\\\}{=+?<>,~`'\"\\s\\p{Cc}]+$"
|
||||
},
|
||||
"example": ["example.com", "www.example.com"]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"visibility": {
|
||||
"type": "string",
|
||||
@@ -43,6 +44,25 @@
|
||||
"description": "Certificates Permissions",
|
||||
"enum": ["hidden", "view", "manage"],
|
||||
"example": "hidden"
|
||||
},
|
||||
"meta": {
|
||||
"type": "object",
|
||||
"description": "Permission metadata",
|
||||
"example": {
|
||||
"proxy_host_ids": [1]
|
||||
},
|
||||
"properties": {
|
||||
"proxy_host_ids": {
|
||||
"type": "array",
|
||||
"description": "Allowed Proxy Host IDs",
|
||||
"example": [1],
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"description": "Proxy Host ID",
|
||||
"example": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 255,
|
||||
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\$/]+(?:/[^\\s\\p{Cc};{}\"'#\\\\]*)?$",
|
||||
"example": "127.0.0.1"
|
||||
},
|
||||
"forward_port": {
|
||||
@@ -108,7 +109,8 @@
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"minLength": 1,
|
||||
"pattern": "^(?!(?:=|~\\*?|\\^~)$)(?:(?:=|~\\*?|\\^~) +)?(?:\"(?:[^\"\\\\\\p{Cc}]|\\\\[^\\p{Cc}])+\"|(?:[^\\s\\p{Cc};{}\"'#\\\\]|\\\\[^\\s\\p{Cc}])+)$"
|
||||
},
|
||||
"forward_scheme": {
|
||||
"$ref": "#/properties/forward_scheme"
|
||||
@@ -120,10 +122,14 @@
|
||||
"$ref": "#/properties/forward_port"
|
||||
},
|
||||
"forward_path": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"pattern": "^[^\\s\\p{Cc};{}\"'#\\\\]*$"
|
||||
},
|
||||
"advanced_config": {
|
||||
"type": "string"
|
||||
},
|
||||
"access_list_id": {
|
||||
"$ref": "../common.json#/properties/access_list_id"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -132,7 +138,8 @@
|
||||
"path": "/app",
|
||||
"forward_scheme": "http",
|
||||
"forward_host": "example.com",
|
||||
"forward_port": 80
|
||||
"forward_port": 80,
|
||||
"access_list_id": 0
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@
|
||||
"forward_domain_name": {
|
||||
"description": "Domain Name",
|
||||
"type": "string",
|
||||
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
|
||||
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
|
||||
"example": "jc21.com"
|
||||
},
|
||||
"preserve_path": {
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
{
|
||||
"description": "Domain Name",
|
||||
"type": "string",
|
||||
"pattern": "^(?:[^.*]+\\.?)+[^.]$",
|
||||
"pattern": "^(?=[^\\s\\p{Cc};{}\"'#\\\\$/]+$)[^.*]+(?:\\.[^.*]+)*\\.?[^.]$",
|
||||
"example": "example.com"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,26 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "400 response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"examples": {
|
||||
"default": {
|
||||
"value": {
|
||||
"error": {
|
||||
"code": 400,
|
||||
"message": "Certificate is in use by 1 proxy host. Remove it from them before deleting it."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "../../../../components/error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,10 @@
|
||||
"dead_hosts": "hidden",
|
||||
"proxy_hosts": "manage",
|
||||
"redirection_hosts": "hidden",
|
||||
"streams": "hidden"
|
||||
"streams": "hidden",
|
||||
"meta": {
|
||||
"proxy_host_ids": [1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% if access_list_id > 0 %}
|
||||
{% if access_list %}
|
||||
{% if access_list.items.length > 0 %}
|
||||
# Authorization
|
||||
auth_basic "Authorization required";
|
||||
@@ -24,4 +25,8 @@
|
||||
{% else %}
|
||||
satisfy all;
|
||||
{% endif %}
|
||||
{% else %}
|
||||
# Access List #{{ access_list_id }} could not be loaded: deny rather than serve unrestricted
|
||||
deny all;
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -2,7 +2,11 @@
|
||||
{% if ssl_forced == 1 or ssl_forced == true %}
|
||||
{% if hsts_enabled == 1 or hsts_enabled == true %}
|
||||
# HSTS (ngx_http_headers_module is required) (63072000 seconds = 2 years)
|
||||
add_header Strict-Transport-Security $hsts_header always;
|
||||
{% if hsts_subdomains == 1 or hsts_subdomains == true %}
|
||||
add_header Strict-Transport-Security $hsts_header_subdomains always;
|
||||
{% else %}
|
||||
add_header Strict-Transport-Security $hsts_header_plain always;
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
@@ -1,3 +1,6 @@
|
||||
map $scheme $hsts_header {
|
||||
https "max-age=63072000;{% if hsts_subdomains == 1 or hsts_subdomains == true -%} includeSubDomains;{% endif %} preload";
|
||||
map $scheme $hsts_header_plain {
|
||||
https "max-age=63072000; preload";
|
||||
}
|
||||
map $scheme $hsts_header_subdomains {
|
||||
https "max-age=63072000;includeSubDomains; preload";
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
location {{ path }} {
|
||||
{{ advanced_config }}
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $forward_host;
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
|
||||
@@ -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 }};
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
/**
|
||||
* Regression tests: values a non-admin can set must never add nginx directives.
|
||||
*
|
||||
* Templates write these values into config as-is, so each one is checked three ways:
|
||||
* - the API rejects injection attempts (real request schemas, real validator)
|
||||
* - accepted values, rendered through the real templates, produce exactly the same nginx
|
||||
* statement structure as a plain value (parsed with nginx's tokenizer rules)
|
||||
* - generateConfig's render-time check refuses unsafe values already stored in the database
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
assertHostRenderable,
|
||||
isValid,
|
||||
isValidClientAddress,
|
||||
isValidStreamHost,
|
||||
patterns,
|
||||
} from "../lib/nginx-values.js";
|
||||
import utils from "../lib/utils.js";
|
||||
import apiValidator from "../lib/validator/api.js";
|
||||
import { getCompiledSchema, getValidationSchema } from "../schema/index.js";
|
||||
|
||||
const TEMPLATES = new URL("../templates/", import.meta.url);
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A tokenizer following ngx_conf_read_token: whitespace separates, ";" ends a statement, "{"
|
||||
// opens a block, "}" closes one, "#" at the start of a token comments to end of line, quotes
|
||||
// group, "\" escapes the next character, and "${" doesn't open a block.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const parseNginx = (text) => {
|
||||
const root = [];
|
||||
const stack = [root];
|
||||
let args = [];
|
||||
let i = 0;
|
||||
const n = text.length;
|
||||
const isSpace = (c) => c === " " || c === "\t" || c === "\r" || c === "\n";
|
||||
|
||||
while (i < n) {
|
||||
const c = text[i];
|
||||
if (isSpace(c)) {
|
||||
i++;
|
||||
} else if (c === "#") {
|
||||
while (i < n && text[i] !== "\n") i++;
|
||||
} else if (c === ";") {
|
||||
if (!args.length) throw new Error(`unexpected ";" at ${i}`);
|
||||
stack.at(-1).push({ name: args[0], args: args.slice(1) });
|
||||
args = [];
|
||||
i++;
|
||||
} else if (c === "{") {
|
||||
if (!args.length) throw new Error(`unexpected "{" at ${i}`);
|
||||
const block = { name: args[0], args: args.slice(1), block: [] };
|
||||
stack.at(-1).push(block);
|
||||
stack.push(block.block);
|
||||
args = [];
|
||||
i++;
|
||||
} else if (c === "}") {
|
||||
if (args.length) throw new Error(`unexpected "}" at ${i}`);
|
||||
if (stack.length === 1) throw new Error(`unbalanced "}" at ${i}`);
|
||||
stack.pop();
|
||||
i++;
|
||||
} else if (c === '"' || c === "'") {
|
||||
let tok = c;
|
||||
i++;
|
||||
while (i < n && text[i] !== c) {
|
||||
if (text[i] === "\\" && i + 1 < n) {
|
||||
tok += text[i] + text[i + 1];
|
||||
i += 2;
|
||||
} else {
|
||||
tok += text[i++];
|
||||
}
|
||||
}
|
||||
if (i >= n) throw new Error("unterminated quoted string");
|
||||
tok += c;
|
||||
i++;
|
||||
if (i < n && !isSpace(text[i]) && text[i] !== ";" && text[i] !== "{") {
|
||||
throw new Error(`unexpected "${text[i]}" after quoted string`);
|
||||
}
|
||||
args.push(tok);
|
||||
} else {
|
||||
let tok = "";
|
||||
while (i < n) {
|
||||
const d = text[i];
|
||||
if (d === "\\" && i + 1 < n) {
|
||||
tok += d + text[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (d === "$" && text[i + 1] === "{") {
|
||||
tok += "${";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (isSpace(d) || d === ";" || d === "{") break;
|
||||
tok += d;
|
||||
i++;
|
||||
}
|
||||
args.push(tok);
|
||||
}
|
||||
}
|
||||
if (args.length) throw new Error("statement without ;");
|
||||
if (stack.length !== 1) throw new Error("unclosed block");
|
||||
return root;
|
||||
};
|
||||
|
||||
// Statement names and argument counts, nested; values themselves don't matter
|
||||
const shape = (stmts) =>
|
||||
stmts.map((s) => `${s.name}/${s.args.length}${s.block ? `{${shape(s.block).join(",")}}` : ""}`);
|
||||
|
||||
const find = (stmts, name) => {
|
||||
for (const s of stmts) {
|
||||
if (s.name === name) return s;
|
||||
if (s.block) {
|
||||
const f = find(s.block, name);
|
||||
if (f) return f;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const render = (file, ctx) =>
|
||||
utils.getRenderEngine().parseAndRender(fs.readFileSync(new URL(file, TEMPLATES), "utf8"), ctx);
|
||||
|
||||
// Same structure as a plain value, or throws (a value that breaks parsing changes it too)
|
||||
const sameShapeAs = async (file, makeCtx, plain, value) => {
|
||||
const base = shape(parseNginx(await render(file, makeCtx(plain))));
|
||||
let got;
|
||||
try {
|
||||
got = shape(parseNginx(await render(file, makeCtx(value))));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(got) === JSON.stringify(base);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Corpora. "injections" add or change directives when rendered unchecked; "rejected" are other
|
||||
// values the rules refuse (they would break or confuse the config without injecting).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
const cases = {
|
||||
locationPath: {
|
||||
valid: [
|
||||
"/",
|
||||
"/api",
|
||||
"/api/",
|
||||
"= /exact",
|
||||
"~ \\.php$",
|
||||
"~* \\.(jpg|png|gif)$",
|
||||
"^~ /static/",
|
||||
'~ "^/v[0-9]{1,2}/"',
|
||||
'"/with space"',
|
||||
"/über",
|
||||
"/a$b",
|
||||
"/a\\;b",
|
||||
],
|
||||
injections: [
|
||||
"/x { return 200; } location /y",
|
||||
"/x {} include /etc/passwd; location /y",
|
||||
"/a; } server { listen 8080",
|
||||
"/a\nreturn 200 x;\nlocation /b",
|
||||
'"/a" { return 200; } location "/b"',
|
||||
"/a {",
|
||||
],
|
||||
rejected: ["", " /a", "/a b", "/a#", "/a\\", '"/a', '"/a\\"', "'/a'", "~", "~ /a\tb", "/a}", "/a\u0000"],
|
||||
},
|
||||
forwardHost: {
|
||||
valid: ["127.0.0.1", "backend", "my_service.internal", "10.0.0.1/path/", "backend/$1", "[::1]", "host:8080"],
|
||||
injections: [
|
||||
"a; return 200; #",
|
||||
'a"; return 200; set $x "',
|
||||
"a:80; } location /x { return 200",
|
||||
"a\nreturn 200;",
|
||||
],
|
||||
rejected: ["", "a b", "a'", "a{", "a}", "a#b", "a\\b", "$http_host", "a$b", "/path", "a/b c"],
|
||||
},
|
||||
forwardPath: {
|
||||
valid: ["", "/", "/x", "/x/$1/"],
|
||||
injections: ["/x; return 200", "/x\nreturn 200"],
|
||||
rejected: ["/x y", "/x#", "/x{", "/x\\"],
|
||||
},
|
||||
redirectDomain: {
|
||||
valid: [
|
||||
"example.com",
|
||||
"example.com:8443",
|
||||
"example.com/path",
|
||||
"sub.example.co.uk/a?b=c",
|
||||
"example.com/$1",
|
||||
"ab",
|
||||
],
|
||||
injections: ["example.com; return 200 x; #", "example.com;\nreturn 200", "a.com { return 200; }"],
|
||||
rejected: ["a b.com", "a.com#x", 'a.com"', "a.com\\", ".com", "a..b", "a", "a.com."],
|
||||
},
|
||||
streamHost: {
|
||||
valid: ["example.com", "1.2.3.4", "::1", "backend.internal"],
|
||||
injections: ["a.com:80; } server { listen 9999", "a.com;\nlisten 1"],
|
||||
rejected: ["a.com/x", "$host", "a b", "a.com#"],
|
||||
},
|
||||
domainName: {
|
||||
valid: ["example.com", "*.example.com", "xn--bcher-kva.example", "a-b.example"],
|
||||
injections: ["a.com\nreturn 200 x", "a.com\n} server {"],
|
||||
rejected: ["a\tb.com", "a;b", "a{b", "a b"],
|
||||
},
|
||||
clientAddress: {
|
||||
valid: ["192.168.0.1", "10.0.0.0/8", "all", "::1", "2001:db8::/32", "::ffff:192.168.1.1", "fe80::1"],
|
||||
injections: ["::1%x; allow all", "::1%x\nallow all", "1.2.3.4; allow all"],
|
||||
rejected: ["fe80::1%eth0", "s::1", "1.2.3.4/33", "::ffff:1d.1.1.1"],
|
||||
},
|
||||
accessUsername: {
|
||||
valid: ["admin", "john smith", "ü"],
|
||||
injections: [],
|
||||
rejected: ["a:b", "a\nroot:$apr1$x$y", "\u0000"],
|
||||
},
|
||||
};
|
||||
|
||||
const checkers = {
|
||||
locationPath: (v) => isValid("locationPath", v),
|
||||
forwardHost: (v) => isValid("forwardHost", v),
|
||||
forwardPath: (v) => isValid("forwardPath", v),
|
||||
redirectDomain: (v) => isValid("redirectDomain", v),
|
||||
streamHost: isValidStreamHost,
|
||||
domainName: (v) => isValid("domainName", v),
|
||||
clientAddress: isValidClientAddress,
|
||||
accessUsername: (v) => isValid("accessUsername", v),
|
||||
};
|
||||
|
||||
describe("nginx value rules", () => {
|
||||
for (const [name, { valid, injections, rejected }] of Object.entries(cases)) {
|
||||
it(`${name}: accepts valid values`, () => {
|
||||
for (const v of valid) assert.ok(checkers[name](v), `should accept ${JSON.stringify(v)}`);
|
||||
});
|
||||
it(`${name}: rejects injections and other unsafe values`, () => {
|
||||
for (const v of [...injections, ...rejected]) {
|
||||
assert.ok(!checkers[name](v), `should reject ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("redirect domain rule matches the old rule for token-safe values", () => {
|
||||
const old = /^(?:[^.*]+\.?)+[^.]$/u;
|
||||
const corpus = [
|
||||
"ab",
|
||||
"a.b",
|
||||
"a.b.c",
|
||||
"a..b",
|
||||
".a",
|
||||
"a.",
|
||||
"a*",
|
||||
"a.*",
|
||||
"*a",
|
||||
"a*b",
|
||||
"a.b:80/x",
|
||||
"x",
|
||||
"ab.",
|
||||
"a.b.",
|
||||
];
|
||||
for (const v of corpus) {
|
||||
assert.equal(isValid("redirectDomain", v), old.test(v), `differs for ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("domain rules don't backtrack exponentially", () => {
|
||||
const evil = `${"a".repeat(30000)}.`;
|
||||
const start = process.hrtime.bigint();
|
||||
isValid("redirectDomain", evil);
|
||||
isValidStreamHost(evil);
|
||||
const ms = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
assert.ok(ms < 1000, `took ${ms}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("API request validation", async () => {
|
||||
await getCompiledSchema();
|
||||
|
||||
const location = (o) => ({ path: "/api", forward_scheme: "http", forward_host: "backend", forward_port: 80, ...o });
|
||||
const proxyHost = (o) => ({
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 80,
|
||||
...o,
|
||||
});
|
||||
const redirection = (o) => ({
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_http_code: 301,
|
||||
forward_domain_name: "example.org",
|
||||
...o,
|
||||
});
|
||||
const stream = (o) => ({
|
||||
incoming_port: 9000,
|
||||
forwarding_host: "backend",
|
||||
forwarding_port: 80,
|
||||
tcp_forwarding: true,
|
||||
udp_forwarding: false,
|
||||
...o,
|
||||
});
|
||||
|
||||
// [field, endpoint, method, payload builder]
|
||||
const endpoints = {
|
||||
locationPath: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ locations: [location({ path: v })] })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ path: v })] })],
|
||||
],
|
||||
forwardHost: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ forward_host: v })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ forward_host: v })],
|
||||
["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_host: v })] })],
|
||||
],
|
||||
forwardPath: [["/nginx/proxy-hosts/{hostID}", "put", (v) => ({ locations: [location({ forward_path: v })] })]],
|
||||
redirectDomain: [
|
||||
["/nginx/redirection-hosts", "post", (v) => redirection({ forward_domain_name: v })],
|
||||
["/nginx/redirection-hosts/{hostID}", "put", (v) => ({ forward_domain_name: v })],
|
||||
],
|
||||
streamHost: [
|
||||
["/nginx/streams", "post", (v) => stream({ forwarding_host: v })],
|
||||
["/nginx/streams/{streamID}", "put", (v) => ({ forwarding_host: v })],
|
||||
],
|
||||
domainName: [
|
||||
["/nginx/proxy-hosts", "post", (v) => proxyHost({ domain_names: [v] })],
|
||||
["/nginx/redirection-hosts", "post", (v) => redirection({ domain_names: [v] })],
|
||||
["/nginx/dead-hosts", "post", (v) => ({ domain_names: [v] })],
|
||||
["/nginx/certificates", "post", (v) => ({ provider: "letsencrypt", domain_names: [v] })],
|
||||
],
|
||||
clientAddress: [
|
||||
["/nginx/access-lists", "post", (v) => ({ name: "x", clients: [{ directive: "allow", address: v }] })],
|
||||
["/nginx/access-lists/{listID}", "put", (v) => ({ clients: [{ directive: "allow", address: v }] })],
|
||||
],
|
||||
accessUsername: [
|
||||
["/nginx/access-lists", "post", (v) => ({ name: "x", items: [{ username: v, password: "pw" }] })],
|
||||
["/nginx/access-lists/{listID}", "put", (v) => ({ items: [{ username: v, password: "pw" }] })],
|
||||
],
|
||||
};
|
||||
|
||||
const validate = (path, method, payload) => {
|
||||
const schema = getValidationSchema(path, method);
|
||||
assert.ok(schema, `no request schema for ${method} ${path}`);
|
||||
return apiValidator(schema, payload);
|
||||
};
|
||||
|
||||
for (const [name, list] of Object.entries(endpoints)) {
|
||||
for (const [path, method, build] of list) {
|
||||
it(`${method.toUpperCase()} ${path} ${name}: rejects injections`, async () => {
|
||||
for (const v of [...cases[name].injections, ...cases[name].rejected]) {
|
||||
if (v === "") continue; // minLength cases, covered by the rules above
|
||||
await assert.rejects(validate(path, method, build(v)), /must/, `accepted ${JSON.stringify(v)}`);
|
||||
}
|
||||
});
|
||||
it(`${method.toUpperCase()} ${path} ${name}: accepts valid values`, async () => {
|
||||
for (const v of cases[name].valid) {
|
||||
if (name === "forwardHost" && v === "") continue;
|
||||
await validate(path, method, build(v));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("schema patterns are the ones the render-time check uses", async () => {
|
||||
const s = await getCompiledSchema();
|
||||
const body = (path, method) => s.paths[path][method].requestBody.content["application/json"].schema;
|
||||
const ph = body("/nginx/proxy-hosts", "post").properties;
|
||||
const loc = ph.locations.items.properties;
|
||||
const al = body("/nginx/access-lists", "post").properties;
|
||||
assert.equal(ph.domain_names.items.pattern, patterns.domainName);
|
||||
assert.equal(body("/nginx/certificates", "post").properties.domain_names.items.pattern, patterns.domainName);
|
||||
assert.equal(ph.forward_host.pattern, patterns.forwardHost);
|
||||
assert.equal(loc.path.pattern, patterns.locationPath);
|
||||
assert.equal(loc.forward_host.pattern, patterns.forwardHost);
|
||||
assert.equal(loc.forward_path.pattern, patterns.forwardPath);
|
||||
assert.equal(
|
||||
body("/nginx/redirection-hosts", "post").properties.forward_domain_name.pattern,
|
||||
patterns.redirectDomain,
|
||||
);
|
||||
assert.equal(body("/nginx/streams", "post").properties.forwarding_host.anyOf[0].pattern, patterns.streamDomain);
|
||||
assert.equal(al.clients.items.properties.address.oneOf[1].pattern, patterns.clientIpv6);
|
||||
assert.equal(al.items.items.properties.username.pattern, patterns.accessUsername);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendered templates", () => {
|
||||
const locationCtx = (field) => (v) => ({
|
||||
path: "/api",
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 8080,
|
||||
forward_path: "",
|
||||
advanced_config: "",
|
||||
access_list_id: 0,
|
||||
[field]: v,
|
||||
});
|
||||
const proxyCtx = (field) => (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_host: "backend",
|
||||
forward_port: 80,
|
||||
certificate_id: 0,
|
||||
locations: "",
|
||||
advanced_config: "",
|
||||
use_default_location: true,
|
||||
ipv6: true,
|
||||
[field]: v,
|
||||
});
|
||||
const redirectCtx = (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
domain_names: ["example.com"],
|
||||
forward_scheme: "http",
|
||||
forward_http_code: 301,
|
||||
forward_domain_name: v,
|
||||
preserve_path: true,
|
||||
advanced_config: "",
|
||||
use_default_location: true,
|
||||
ipv6: true,
|
||||
});
|
||||
const streamCtx = (v) => ({
|
||||
enabled: true,
|
||||
id: 1,
|
||||
incoming_port: 9000,
|
||||
forwarding_host: v,
|
||||
forwarding_port: 80,
|
||||
tcp_forwarding: true,
|
||||
udp_forwarding: true,
|
||||
ipv6: true,
|
||||
});
|
||||
const accessCtx = (v) => ({
|
||||
access_list_id: 1,
|
||||
access_list: { items: [], clients: [{ directive: "allow", address: v }], satisfy_any: false },
|
||||
});
|
||||
|
||||
// A location modifier is a separate argument, so compare with a plain path using the same one
|
||||
const plainPath = (v) => {
|
||||
const m = v.match(/^(=|~\*?|\^~) +/);
|
||||
return m ? `${m[1]} /api` : "/api";
|
||||
};
|
||||
|
||||
// [template, context builder, plain value (or builder), injections that apply to it]
|
||||
const targets = {
|
||||
locationPath: [["_location.conf", locationCtx("path"), plainPath]],
|
||||
forwardHost: [
|
||||
["_location.conf", locationCtx("forward_host"), "backend"],
|
||||
// Rendered inside double quotes here, so only a quote can break out
|
||||
["proxy_host.conf", proxyCtx("forward_host"), "backend", ['a"; return 200; set $x "', 'a" ; } server { "']],
|
||||
],
|
||||
forwardPath: [["_location.conf", locationCtx("forward_path"), "/x"]],
|
||||
redirectDomain: [["redirection_host.conf", redirectCtx, "example.org"]],
|
||||
streamHost: [["stream.conf", streamCtx, "backend"]],
|
||||
domainName: [["proxy_host.conf", (v) => proxyCtx("domain_names")([v]), "example.com"]],
|
||||
clientAddress: [["_access.conf", accessCtx, "10.0.0.1"]],
|
||||
};
|
||||
|
||||
for (const [name, list] of Object.entries(targets)) {
|
||||
for (const [file, ctx, plain, injections = cases[name].injections] of list) {
|
||||
const plainFor = (v) => (typeof plain === "function" ? plain(v) : plain);
|
||||
it(`${file} ${name}: accepted values keep the statement structure`, async () => {
|
||||
for (const v of cases[name].valid) {
|
||||
if (v === "" && name !== "forwardPath") continue;
|
||||
// generateConfig brackets IPv6 stream hosts before rendering
|
||||
const value = name === "streamHost" && v.includes(":") ? `[${v}]` : v;
|
||||
assert.ok(
|
||||
await sameShapeAs(file, ctx, plainFor(v), value),
|
||||
`${JSON.stringify(v)} changed the config`,
|
||||
);
|
||||
}
|
||||
});
|
||||
it(`${file} ${name}: the injection corpus would change it unchecked`, async () => {
|
||||
assert.ok(injections.length > 0);
|
||||
for (const v of injections) {
|
||||
assert.ok(!checkers[name](v), `rules accept ${JSON.stringify(v)}`);
|
||||
assert.ok(
|
||||
!(await sameShapeAs(file, ctx, plainFor(v), v)),
|
||||
`${JSON.stringify(v)} is not an injection`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it("location path renders as the location's own arguments", async () => {
|
||||
for (const v of cases.locationPath.valid) {
|
||||
const loc = find(parseNginx(await render("_location.conf", locationCtx("path")(v))), "location");
|
||||
assert.equal(loc.args.join(" "), v.replace(/ +/, " "));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("render-time check (rows saved before validation)", () => {
|
||||
const host = (o) => ({
|
||||
id: 7,
|
||||
domain_names: ["example.com"],
|
||||
forward_host: "backend",
|
||||
locations: [],
|
||||
...o,
|
||||
});
|
||||
|
||||
it("passes valid hosts", () => {
|
||||
assertHostRenderable("proxy_host", host({ locations: [{ path: "~* \\.php$", forward_host: "10.0.0.1/x/" }] }));
|
||||
assertHostRenderable("redirection_host", host({ forward_domain_name: "example.org/path" }));
|
||||
assertHostRenderable("dead_host", host({}));
|
||||
assertHostRenderable("stream", { id: 1, forwarding_host: "::1" });
|
||||
assertHostRenderable(
|
||||
"proxy_host",
|
||||
host({ access_list: { clients: [{ directive: "allow", address: "10.0.0.0/8" }] } }),
|
||||
);
|
||||
});
|
||||
|
||||
it("still renders stored values the API no longer accepts but that can't add directives", () => {
|
||||
// e.g. an IPv6 literal server name, as the host-header smoke test renders
|
||||
assertHostRenderable("proxy_host", host({ domain_names: ["host.example.test", "[::1]"] }));
|
||||
assertHostRenderable("proxy_host", host({ forward_host: "$upstream" }));
|
||||
assertHostRenderable("redirection_host", host({ forward_domain_name: "a" }));
|
||||
assertHostRenderable("stream", { id: 1, forwarding_host: "a.com/x" });
|
||||
});
|
||||
|
||||
const refuse = (type, row, what) => {
|
||||
assert.throws(
|
||||
() => assertHostRenderable(type, row),
|
||||
(err) => err.unsafeNginxValue === true && err.message.includes(what),
|
||||
);
|
||||
};
|
||||
|
||||
it("refuses every injected field", () => {
|
||||
for (const v of cases.locationPath.injections) {
|
||||
refuse("proxy_host", host({ locations: [{ path: v, forward_host: "b" }] }), "location path");
|
||||
}
|
||||
for (const v of cases.forwardHost.injections) {
|
||||
refuse("proxy_host", host({ forward_host: v }), "forward host");
|
||||
refuse("proxy_host", host({ locations: [{ path: "/a", forward_host: v }] }), "location forward host");
|
||||
}
|
||||
for (const v of cases.forwardPath.injections) {
|
||||
refuse(
|
||||
"proxy_host",
|
||||
host({ locations: [{ path: "/a", forward_host: "b", forward_path: v }] }),
|
||||
"forward path",
|
||||
);
|
||||
}
|
||||
for (const v of cases.redirectDomain.injections) {
|
||||
refuse("redirection_host", host({ forward_domain_name: v }), "forward domain");
|
||||
}
|
||||
for (const v of cases.streamHost.injections) {
|
||||
refuse("stream", { id: 1, forwarding_host: v }, "forwarding host");
|
||||
}
|
||||
for (const v of cases.domainName.injections) {
|
||||
refuse("dead_host", host({ domain_names: [v] }), "domain name");
|
||||
}
|
||||
for (const v of cases.clientAddress.injections) {
|
||||
refuse("proxy_host", host({ access_list: { clients: [{ directive: "allow", address: v }] } }), "address");
|
||||
}
|
||||
});
|
||||
});
|
||||
+480
-447
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
|
||||
|
||||
@@ -37,6 +37,17 @@ services:
|
||||
- website1.example.com
|
||||
- website2.example.com
|
||||
- website3.example.com
|
||||
- website4.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
|
||||
@@ -105,6 +116,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"
|
||||
|
||||
@@ -16,6 +16,7 @@ services:
|
||||
- website1.example.com
|
||||
- website2.example.com
|
||||
- website3.example.com
|
||||
- website4.example.com
|
||||
environment:
|
||||
TZ: "${TZ:-Australia/Brisbane}"
|
||||
PUID: 1000
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -17,6 +17,9 @@ server {
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# The TCP peer before realip processing. In development there is no peer secret
|
||||
# (see production.conf.template), so the backend ignores this and limits on the socket
|
||||
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
|
||||
proxy_read_timeout 15m;
|
||||
|
||||
@@ -3,7 +3,7 @@ location ~* ^.*\.(css|js|jpe?g|gif|png|webp|woff|woff2|eot|ttf|svg|ico|css\.map|
|
||||
|
||||
# use the public cache
|
||||
proxy_cache public-cache;
|
||||
proxy_cache_key $host$request_uri;
|
||||
proxy_cache_key $forward_host$request_uri;
|
||||
|
||||
# ignore these headers for media
|
||||
proxy_ignore_headers Set-Cookie Cache-Control Expires X-Accel-Expires;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
add_header X-Served-By $host;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header Host $forward_host;
|
||||
proxy_set_header X-Forwarded-Scheme $x_forwarded_scheme;
|
||||
proxy_set_header X-Forwarded-Proto $x_forwarded_proto;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
+7
-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,11 @@ server {
|
||||
proxy_set_header X-Forwarded-Scheme $scheme;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
# The TCP peer before realip processing; the backend rate limits on this because
|
||||
# X-Real-IP can be set by any client in the trusted private ranges. The secret, generated
|
||||
# at startup, is how the backend knows this header came from here.
|
||||
proxy_set_header X-NPM-Peer-Addr $realip_remote_addr;
|
||||
proxy_set_header X-NPM-Peer-Secret "{{NPM_PEER_SECRET}}";
|
||||
proxy_pass http://127.0.0.1:3000/;
|
||||
|
||||
proxy_read_timeout 15m;
|
||||
@@ -52,6 +52,18 @@ http {
|
||||
# Dynamically generated resolvers file
|
||||
include /etc/nginx/conf.d/include/resolvers[.]conf;
|
||||
|
||||
# Forwarded Host: nginx's normalised $host (lowercased, taken from the request line for
|
||||
# absolute-form requests) plus the client's port, so upstreams that compare Host with
|
||||
# Origin/Referer on non-standard ports keep working. The port is only carried over when
|
||||
# the Host header names the same host nginx routed on; otherwise (no Host, absolute-form
|
||||
# URI naming a different host) it falls back to bare $host. Using $host rather than the
|
||||
# raw header keeps case variants and mismatched Host values out of upstream requests and
|
||||
# the asset cache key.
|
||||
map "$host|$http_host" $forward_host {
|
||||
"~*^(?<fwd_name>[^|]+)\|\k<fwd_name>(?<fwd_port>:[0-9]+)$" "$host$fwd_port";
|
||||
default $host;
|
||||
}
|
||||
|
||||
# Default upstream scheme
|
||||
map $host $forward_scheme {
|
||||
default http;
|
||||
|
||||
@@ -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
|
||||
@@ -39,3 +39,7 @@ touch /var/log/nginx/error.log || true
|
||||
chmod 777 /var/log/nginx/error.log || true
|
||||
chmod -R 777 /var/cache/nginx || true
|
||||
chmod 644 /etc/logrotate.d/nginx-proxy-manager
|
||||
|
||||
# nginx has not been started yet, so any pid file here is stale. Remove it,
|
||||
# otherwise a pid file owned by another user stops nginx starting as PUID:PGID
|
||||
rm -f /run/nginx/nginx.pid
|
||||
@@ -46,6 +46,14 @@ 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
|
||||
if [ "$(stat -c '%u:%g' '/data/keys.json')" != "$PUID:$PGID" ]; then
|
||||
chown "$PUID:$PGID" /data/keys.json
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$(is_true "${SKIP_CERTBOT_OWNERSHIP:-}")" = '1' ]; then
|
||||
log_info 'Skipping ownership change of certbot directories'
|
||||
else
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
#!/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"
|
||||
|
||||
# A new secret on every start. The admin nginx sends it with X-NPM-Peer-Addr, and the backend
|
||||
# only trusts that header when the secret matches, so a proxy host or stream forwarding to the
|
||||
# backend port can't pass a forged one through.
|
||||
NPM_PEER_SECRET="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
if [ "${#NPM_PEER_SECRET}" -ne 64 ]; then
|
||||
log_fatal "Failed to generate the backend peer secret"
|
||||
fi
|
||||
|
||||
SED_REGEX="s/\{\{NPM_ADMIN_PORT\}\}/${NPM_ADMIN_PORT}/g;s/\{\{NPM_PEER_SECRET\}\}/${NPM_PEER_SECRET}/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"
|
||||
# Hand the secret to the backend; with-contenv loads this directory into its environment
|
||||
printf '%s' "$NPM_PEER_SECRET" > /var/run/s6/container_environment/NPM_PEER_SECRET
|
||||
else
|
||||
log_fatal "Failed to generate ${PRODFILE} from template"
|
||||
fi
|
||||
fi
|
||||
@@ -26,7 +26,10 @@ process_folder () {
|
||||
do
|
||||
echo "- ${FILE}"
|
||||
TMPFILE="${FILE}.tmp"
|
||||
if sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
|
||||
|
||||
if is_mounted "${FILE}"; then
|
||||
echo "WARNING: skipping ${FILE} — mounted file" >&2
|
||||
elif sed -E "$SED_REGEX" "$FILE" > "$TMPFILE" && [ -s "$TMPFILE" ]; then
|
||||
mv "$TMPFILE" "$FILE"
|
||||
else
|
||||
echo "WARNING: skipping ${FILE} — sed produced empty output" >&2
|
||||
|
||||
@@ -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.2
|
||||
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`
|
||||
+435
-435
File diff suppressed because it is too large.
Load diff
+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.6.6/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
+4
@@ -23,12 +23,16 @@ const allLocales = [
|
||||
["cs", "cs-CZ"],
|
||||
["vi", "vi-VN"],
|
||||
["zh", "zh-CN"],
|
||||
["zh-tw", "zh-TW"],
|
||||
["ko", "ko-KR"],
|
||||
["bg", "bg-BG"],
|
||||
["id", "id-ID"],
|
||||
["tr", "tr-TR"],
|
||||
["hu", "hu-HU"],
|
||||
["no", "no-NO"],
|
||||
["uk", "uk-UA"],
|
||||
["az", "az-AZ"],
|
||||
["fa", "fa-IR"],
|
||||
];
|
||||
|
||||
const ignoreUnused = [/^.*$/];
|
||||
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user